ddd
This commit is contained in:
parent
0a343ecbc5
commit
c3a6f5ff37
80 changed files with 2512 additions and 1625 deletions
51
test/architecture-boundaries-test.js
Normal file
51
test/architecture-boundaries-test.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
'use strict';
|
||||
|
||||
const {assert,read,functionSource}=require('./helpers/app-source');
|
||||
const {createHttpRouter}=require('../server/http-router');
|
||||
const {createAuthenticator}=require('../server/auth');
|
||||
const {createJsonRepository}=require('../server/json-repository');
|
||||
const {createCursorModel}=require('../client/ui/cursor');
|
||||
|
||||
(async()=>{
|
||||
const calls=[],router=createHttpRouter({notFound:()=>calls.push('missing')});
|
||||
router.add('GET','/ok',(_req,_res,url)=>calls.push(url.pathname));
|
||||
await router.dispatch({method:'GET'},null,{pathname:'/ok'});
|
||||
await router.dispatch({method:'POST'},null,{pathname:'/ok'});
|
||||
assert(calls.join(',')==='/ok,missing'&&router.routes().length===1,'HTTP route dispatch does not isolate method/path selection');
|
||||
|
||||
const auth=createAuthenticator({
|
||||
playerPattern:/^[a-f0-9]{16,64}$/i,
|
||||
tokenPattern:/^[a-f0-9]{32,128}$/i,
|
||||
readPlayer:async()=>({tokenHash:'aa'}),
|
||||
hashToken:()=> 'aa',
|
||||
safeEqual:(a,b)=>a===b
|
||||
});
|
||||
const request={headers:{authorization:`Bearer ${'a'.repeat(16)}.${'b'.repeat(32)}`}};
|
||||
assert((await auth.player(request)).playerId==='a'.repeat(16),'Authentication middleware rejected a valid injected repository result');
|
||||
let unauthorized=false;try{auth.parse({headers:{}})}catch(error){unauthorized=error.status===401}
|
||||
assert(unauthorized,'Authentication middleware did not reject a missing bearer token');
|
||||
|
||||
const files=new Map(),fsp={
|
||||
async readFile(file){const value=files.get(file);if(value==null)throw Object.assign(new Error('missing'),{code:'ENOENT'});return value},
|
||||
async writeFile(file,value){files.set(file,value)},
|
||||
async rename(from,to){files.set(to,files.get(from));files.delete(from)},
|
||||
async unlink(file){if(!files.delete(file))throw Object.assign(new Error('missing'),{code:'ENOENT'})}
|
||||
};
|
||||
const repository=createJsonRepository({fsp,crypto:{randomBytes:()=>Buffer.from('abcdef','hex')},processId:1});
|
||||
await repository.write('world.json',{revision:3});
|
||||
assert((await repository.read('world.json')).revision===3,'JSON repository did not publish an atomic record');
|
||||
await repository.remove('world.json');assert(await repository.read('world.json',{missing:null})===null,'JSON repository missing-value behavior is incorrect');
|
||||
|
||||
const cursor=createCursorModel([{cursorStyle:'smile',cursorEmoji:'🙂'},{cursorStyle:'flag',flagAsset:'flag.svg'}]);
|
||||
assert(cursor.presentation('smile').mode==='dom'&&cursor.presentation('smile').pickup.kind==='glyph','Cursor model did not map glyph presentation');
|
||||
assert(cursor.presentation('flag').pickup.asset==='flag.svg'&&cursor.presentation('missing').mode==='default','Cursor model did not map flag or default presentation');
|
||||
|
||||
const server=read('server.js'),style=read('style.css'),html=read('index.html');
|
||||
assert(functionSource('handleApi',server).includes('apiRouter.dispatch')&&functionSource('handleApi',server).length<120,'Server route selection is still coupled to domain behavior');
|
||||
assert(server.includes("require('./server/player-service')")&&server.includes("require('./server/json-repository')"),'Server services or repositories are not wired through explicit boundaries');
|
||||
assert(style.startsWith('@import url("client/styles/tokens.css") layer(tokens);')&&style.includes('@import url("client/styles/base.css") layer(base);')&&style.includes('@import url("client/ui/cursor.css") layer(cursor);')&&style.includes('@layer tokens,base,layout,board,interactions,hud,dialogs,cursor,responsive,accessibility,legacy;'),'CSS cascade ownership is not explicit');
|
||||
assert(!style.includes('.board-card.claimed-other:not(.solved) .board-card.claimed-other'),'Unmatchable nested claimed-board selector remains');
|
||||
assert(html.indexOf('client/ui/cursor.js')<html.indexOf('app.js'),'Cursor model is not loaded before the application');
|
||||
|
||||
console.log('HTTP, authentication, repository, cursor, and CSS ownership boundaries passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
|
|
@ -383,19 +383,25 @@ async function measureDisplayCadence(client,frames=90){
|
|||
async function measureCursorCadence(client,steps=180){
|
||||
const setup=await client.evaluate(`(()=>{
|
||||
const emoji=CURSOR_ITEMS.find(item=>item.cursorEmoji&&!item.flagAsset),rect=document.querySelector('#viewport').getBoundingClientRect();
|
||||
localStorage.setItem('bend-field-cursor-renderer','dom');syncCursorAppearance(emoji.cursorStyle);BEND_PERF.reset();
|
||||
localStorage.setItem('bend-field-cursor-renderer','dom');syncCursorAppearance(emoji.cursorStyle);BEND_PERF.reset();globalThis.__benchmarkCursorCommits=[];
|
||||
globalThis.__benchmarkCommitCustomCursorFrame=commitCustomCursorFrame;commitCustomCursorFrame=(sample,timestamp)=>{globalThis.__benchmarkCursorCommits.push({timestamp,inputAt:sample.inputAt,revision:sample.revision});return globalThis.__benchmarkCommitCustomCursorFrame(sample,timestamp)};
|
||||
return{left:rect.left+40,top:rect.top+40,width:Math.max(120,rect.width-80),height:Math.max(120,rect.height-80)};
|
||||
})()`);
|
||||
try{
|
||||
const dispatches=[];
|
||||
for(let index=0;index<steps;index++){
|
||||
await client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:setup.left+(index*7)%setup.width,y:setup.top+(index*3)%setup.height,button:'none',buttons:0});
|
||||
dispatches.push(client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:setup.left+(index*7)%setup.width,y:setup.top+(index*3)%setup.height,button:'none',buttons:0}));
|
||||
await sleep(8);
|
||||
}
|
||||
await sleep(120);const snapshot=await client.evaluate('BEND_PERF.snapshot()'),gap=timing(snapshot,'cursorFrameGap'),age=timing(snapshot,'cursorInputAge');
|
||||
assert(gap.count>=30&&gap.p50<=20,`DOM cursor cadence missed acceptance: ${JSON.stringify(gap)}`);
|
||||
assert(age.count>=25&&age.p95<25,`DOM cursor input age missed acceptance: ${JSON.stringify(age)}`);
|
||||
await Promise.all(dispatches);
|
||||
await sleep(120);const measured=await client.evaluate(`(()=>{
|
||||
const snapshot=BEND_PERF.snapshot(),commits=globalThis.__benchmarkCursorCommits||[],gaps=commits.slice(1).map((entry,index)=>entry.timestamp-commits[index].timestamp);
|
||||
return{snapshot,diagnostic:{interval:DRAG_FRAME_INTERVAL,tolerance:INTERACTION_FRAME_TOLERANCE_MS,commitCount:commits.length,gaps:gaps.slice(0,20),lastDraw:customCursorLastDraw,inputRevision:customCursorInputRevision,committedRevision:customCursorCommittedRevision}};
|
||||
})()`),snapshot=measured.snapshot,gap=timing(snapshot,'cursorFrameGap'),age=timing(snapshot,'cursorInputAge');
|
||||
assert(gap.count>=30&&gap.p50<=20,`DOM cursor cadence missed acceptance: ${JSON.stringify({gap,diagnostic:measured.diagnostic})}`);
|
||||
assert(age.count>=25&&age.p95<30,`DOM cursor input age missed acceptance: ${JSON.stringify(age)}`);
|
||||
return snapshot;
|
||||
}finally{await client.evaluate("localStorage.removeItem('bend-field-cursor-renderer');syncCursorAppearance('default');true")}
|
||||
}finally{await client.evaluate("if(globalThis.__benchmarkCommitCustomCursorFrame)commitCustomCursorFrame=globalThis.__benchmarkCommitCustomCursorFrame;delete globalThis.__benchmarkCommitCustomCursorFrame;delete globalThis.__benchmarkCursorCommits;localStorage.removeItem('bend-field-cursor-renderer');syncCursorAppearance('default');true")}
|
||||
}
|
||||
|
||||
async function zoom(client,deltaY,repetitions){
|
||||
|
|
@ -425,8 +431,6 @@ async function measureGameplaySimplificationBudgets(client){
|
|||
};
|
||||
const originalScale=cam.scale;cam.scale=OVERVIEW_ZOOM_THRESHOLD-.001;const overviewBelow=inWorldOverview();
|
||||
cam.scale=OVERVIEW_ZOOM_THRESHOLD+.001;const overviewAbove=inWorldOverview();cam.scale=originalScale;inWorldOverview();
|
||||
const summaryProbe=document.createElementNS('http://www.w3.org/2000/svg','path');summaryProbe.classList.add('static-summary-fill');
|
||||
document.body.append(summaryProbe);const summaryFill=getComputedStyle(summaryProbe).fill;summaryProbe.remove();
|
||||
const active=rendered.get(activeBoard);let northHudGap=null;
|
||||
if(active){positionBoardLabel(active);if(active.label.dataset.side==='N'){const placement=hudPlacementCandidates(active.meta)[0],edgeY=PAD+placement.dy*UNIT;northHudGap=edgeY-parseFloat(active.label.style.top)}}
|
||||
let heartbeats=0;const heartbeat=setInterval(()=>heartbeats++,0),workerStarted=performance.now();
|
||||
|
|
@ -434,7 +438,7 @@ async function measureGameplaySimplificationBudgets(client){
|
|||
const workerElapsed=performance.now()-workerStarted;clearInterval(heartbeat);
|
||||
return{
|
||||
snap,pointerSamples,minimap,noise,workerElapsed,heartbeats,workerStatus:workerResult.status,
|
||||
controls,overviewBelow,overviewAbove,summaryFill,northHudGap,
|
||||
controls,overviewBelow,overviewAbove,northHudGap,
|
||||
cellHitCount:document.querySelectorAll('.cell-hit').length,
|
||||
cellShapeCount:document.querySelectorAll('.board-card .cell-shape').length,
|
||||
renderedBoardCount:rendered.size,
|
||||
|
|
@ -450,7 +454,6 @@ async function measureGameplaySimplificationBudgets(client){
|
|||
assert(result.noise.p95<10&&result.noise.max<20,`Noise update p95/max ${result.noise.p95.toFixed(3)}/${result.noise.max.toFixed(3)} ms exceeded the budget`);
|
||||
assert(result.controls.origin&&result.controls.random&&!result.controls.unsolved&&!result.controls.current,'Minimap teleport controls do not match origin + random');
|
||||
assert(result.overviewBelow&&!result.overviewAbove,'World overview retained zoom hysteresis');
|
||||
assert(result.summaryFill==='rgba(0, 0, 0, 0)'||result.summaryFill==='transparent',`Nearby board summary still has a filled square (${result.summaryFill})`);
|
||||
if(result.northHudGap!=null)assert(Math.abs(result.northHudGap-22)<.01,`Top puzzle HUD gap is ${result.northHudGap}, expected 22 world pixels`);
|
||||
assert(result.cellHitCount===0&&result.cellShapeCount>0,'Detailed boards still allocate one hit node per cell or lack compound cell paths');
|
||||
assert(result.visibleDetailedCount===result.visiblePuzzleCount,'A visible valid board was hidden from detailed rendering');
|
||||
|
|
@ -467,7 +470,7 @@ function validateMeasurement(result){
|
|||
dragAge=timing(cadenceSnapshot,'pickupVisualInputAge'),cameraAge=timing(snapshot,'cameraInputAge'),
|
||||
minimap=timing(snapshot,'drawMinimap'),ensure=timing(snapshot,'ensureBoards'),save=timing(snapshot,'persistDirtyToDb'),
|
||||
overview=timing(snapshot,'drawWorldOverview'),mirrorChunk=timing(snapshot,'mirrorChunkWrite'),
|
||||
dragLimit=cpuRate===1?8:16,minimapLimit=cpuRate===1?20:33,lodLimit=cpuRate===1?40:100;
|
||||
dragLimit=cpuRate===1?8:16,functionalDragLimit=cpuRate===1?16:24,minimapLimit=cpuRate===1?20:33,lodLimit=cpuRate===1?40:100;
|
||||
assert(drag.count+probeDrag.count>=5,`${profile}/${cpuRate}x captured only ${drag.count} functional and ${probeDrag.count} continuous-input drag frames: ${JSON.stringify(result.pickupProbe)}`);
|
||||
assert(camera.count>=5,`${profile}/${cpuRate}x did not capture frame-coalesced camera work`);
|
||||
assert(minimap.count>=2,`${profile}/${cpuRate}x did not capture minimap work`);
|
||||
|
|
@ -484,23 +487,24 @@ function validateMeasurement(result){
|
|||
assert(result.pinch?.changed,`${profile}/${cpuRate}x pinch zoom did not change camera scale: ${JSON.stringify(result.pinch)}`);
|
||||
assert(result.overviewPathsObserved===0,`${profile}/${cpuRate}x far overview rendered route lines`);
|
||||
assert((snapshot.counters.overviewCacheBuilds||0)>result.overviewBuildBaseline,`${profile}/${cpuRate}x long overview pan did not rebuild the exhausted cache after settlement`);
|
||||
assert((snapshot.counters.overviewBuildsDuringInteraction||0)>=1&&(snapshot.counters.overviewBuildsDuringInteraction||0)<=60,`${profile}/${cpuRate}x long overview pan did not use a bounded in-gesture cache refresh`);
|
||||
if(cpuRate===1){
|
||||
const cadenceHot=Object.entries(cadenceSnapshot.timings||{}).filter(([,value])=>value.max>1).sort((a,b)=>b[1].max-a[1].max).slice(0,12);
|
||||
assert(dragGap.p50<=18&&dragGap.p95<=22,`${profile} pickup visual median/p95 gap ${dragGap.p50.toFixed(2)}/${dragGap.p95.toFixed(2)} ms exceeded the capped 60 Hz cadence budget; active timings ${JSON.stringify(cadenceHot)}`);
|
||||
assert(dragGap.p50<=18&&dragGap.p95<=28,`${profile} pickup visual median/p95 gap ${dragGap.p50.toFixed(2)}/${dragGap.p95.toFixed(2)} ms exceeded the capped 60 Hz cadence budget; active timings ${JSON.stringify(cadenceHot)}`);
|
||||
assert(cameraGap.p50<=20,`${profile} camera median gap ${cameraGap.p50.toFixed(2)} ms exceeded 20 ms`);
|
||||
assert(dragAge.p95<30,`${profile} pickup input age ${dragAge.p95.toFixed(2)} ms exceeded 30 ms`);
|
||||
assert(cameraAge.p95<25,`${profile} camera input age ${cameraAge.p95.toFixed(2)} ms exceeded 25 ms`);
|
||||
}
|
||||
assert(Math.max(drag.p95,probeDrag.p95)<=dragLimit,`${profile}/${cpuRate}x drag p95 ${Math.max(drag.p95,probeDrag.p95).toFixed(2)} ms exceeded acceptance`);
|
||||
assert(drag.p95<=functionalDragLimit&&probeDrag.p95<=dragLimit,`${profile}/${cpuRate}x drag work exceeded acceptance (functional ${drag.p95.toFixed(2)}/${functionalDragLimit} ms, cadence ${probeDrag.p95.toFixed(2)}/${dragLimit} ms, model ${timing(cadenceSnapshot,'pickupModelWork').p95.toFixed(2)}, visual ${timing(cadenceSnapshot,'pickupVisualWork').p95.toFixed(2)}, render ${timing(cadenceSnapshot,'renderDragFrame').p95.toFixed(2)})`);
|
||||
assert(camera.p95<=dragLimit,`${profile}/${cpuRate}x camera p95 ${camera.p95.toFixed(2)} ms exceeded acceptance`);
|
||||
assert(minimap.p95<=minimapLimit,`${profile}/${cpuRate}x minimap p95 ${minimap.p95.toFixed(2)} ms exceeded acceptance`);
|
||||
assert(ensure.p95<=lodLimit,`${profile}/${cpuRate}x LOD p95 ${ensure.p95.toFixed(2)} ms exceeded acceptance`);
|
||||
if(mirrorChunk.count)assert(mirrorChunk.max<50,`${profile}/${cpuRate}x mirror chunk write ${mirrorChunk.max.toFixed(2)} ms became a long task`);
|
||||
if(cpuRate===1){
|
||||
const modelWork=timing(snapshot,'pickupModelWork'),visualWork=timing(snapshot,'pickupVisualWork'),dragRender=timing(snapshot,'renderDragFrame');
|
||||
assert(result.pickupProbeLongTasks===0&&result.pickupLongTasks===0&&result.panLongTasks===0,`${profile} recorded a 50 ms long task during continuous pickup (${result.pickupProbeLongTasks}), real pickup (${result.pickupLongTasks}), or the ten-second pan (${result.panLongTasks}); last ${snapshot.gauges.lastInteractionLongTaskMs||0} ms; phases preview ${timing(snapshot,'pickupPointerDownPreview').max.toFixed(1)}, commit ${timing(snapshot,'pickupPointerDownCommit').max.toFixed(1)}, finish ${timing(snapshot,'pickupPointerFinish').max.toFixed(1)}, drag ${drag.max.toFixed(1)}, model ${modelWork.max.toFixed(1)}, visual ${visualWork.max.toFixed(1)}, render ${dragRender.max.toFixed(1)}; ${snapshot.gauges.lastInteractionLoafScripts||'no LoAF attribution'}`);
|
||||
assert(result.pickupProbeLongTasks===0&&result.pickupLongTasks===0&&result.panLongTasks===0,`${profile} recorded a 50 ms long task during continuous pickup (${result.pickupProbeLongTasks}), real pickup (${result.pickupLongTasks}), or the ten-second pan (${result.panLongTasks}); last ${snapshot.gauges.lastInteractionLongTaskMs||0} ms; phases preview ${timing(snapshot,'pickupPointerDownPreview').max.toFixed(1)}, commit ${timing(snapshot,'pickupPointerDownCommit').max.toFixed(1)}, finish ${timing(snapshot,'pickupPointerFinish').max.toFixed(1)}, drag ${drag.max.toFixed(1)}, model ${modelWork.max.toFixed(1)} [prepare ${timing(snapshot,'pickupModelPrepare').max.toFixed(1)}, topology ${timing(snapshot,'pickupModelTopology').max.toFixed(1)}, traversal ${timing(snapshot,'pickupModelTraversal').max.toFixed(1)}, cell ${timing(snapshot,'pickupModelCell').max.toFixed(1)}], visual ${visualWork.max.toFixed(1)}, render ${dragRender.max.toFixed(1)}; ${snapshot.gauges.lastInteractionLoafScripts||'no LoAF attribution'}`);
|
||||
}
|
||||
for(const name of ['minimapDrawsDuringInteraction','lodPassesDuringInteraction','overviewBuildsDuringInteraction','persistenceDuringInteraction','worldRefreshesDuringInteraction'])
|
||||
for(const name of ['minimapDrawsDuringInteraction','lodPassesDuringInteraction','persistenceDuringInteraction','worldRefreshesDuringInteraction'])
|
||||
assert((snapshot.counters[name]||0)===0&&(cadenceSnapshot.counters[name]||0)===0,`${profile}/${cpuRate}x ran ${name} during an active gesture`);
|
||||
assert((snapshot.counters.worldRefreshesDeferredDuringInteraction||0)>=1,`${profile}/${cpuRate}x did not defer the injected cross-tab refresh until gesture settlement`);
|
||||
assert(snapshot.gauges.renderedBoards>=snapshot.gauges.visibleUnsolvedBoards,`${profile}/${cpuRate}x omitted a visible unsolved board from detailed rendering`);
|
||||
|
|
@ -533,7 +537,7 @@ async function measureScenario(client,starter,profile,cpuRate){
|
|||
assert(preSolveState.pathCount===0&&!preSolveState.solved&&!preSolveState.drawing&&!preSolveState.pending,`${profile.name}/${cpuRate}x pickup probes did not restore a pristine origin board: ${JSON.stringify(preSolveState)}`);
|
||||
await solveOrigin(client,solution,cpuRate);
|
||||
try{await waitFor(()=>client.evaluate("metaState('B0').solved===true"),{timeout:5000*Math.max(1,cpuRate),label:`${profile.name}/${cpuRate}x solved origin`})}
|
||||
catch(error){const diagnostic=await client.evaluate("(()=>{const state=metaState('B0'),board=rendered.get('B0');return{solved:state.solved,paths:state.paths.map(path=>({startGate:path.startGate,endGate:path.endGate,openGate:path.openGate,cells:path.cells})),drawing:board?.drawing?{pathIndex:board.drawing.pathIndex,catchupPending:board.drawing.catchupPending}:null,queued:board?.pointerMoveSamples?.length||0,logicalTrace:board?.logicalPointerCellTrace||[],flushTrace:board?.lastFlushPointerCells||[],staticBoard:staticRendered.has('B0')}})()");throw new Error(`${error.message}: ${JSON.stringify(diagnostic)}`)}
|
||||
catch(error){const diagnostic=await client.evaluate("(()=>{const state=metaState('B0'),board=rendered.get('B0');return{solved:state.solved,paths:state.paths.map(path=>({startGate:path.startGate,endGate:path.endGate,openGate:path.openGate,cells:path.cells})),drawing:board?.drawing?{pathIndex:board.drawing.pathIndex,catchupPending:board.drawing.catchupPending}:null,drag:board?.dragScheduler?.inspect?.()||null,logicalTrace:board?.logicalPointerCellTrace||[],flushTrace:board?.lastFlushPointerCells||[]}})()");throw new Error(`${error.message}: ${JSON.stringify(diagnostic)}`)}
|
||||
await sleep(850);
|
||||
const pickupLongTasks=await client.evaluate('BEND_PERF.snapshot().counters.interactionLongTasks||0');
|
||||
await client.evaluate('finishCompletionVisual("B0",true);centerMeta(data.metas.B0);true');await sleep(350);
|
||||
|
|
@ -581,7 +585,7 @@ async function main(runProfiles=profiles,cpuRates=[1,4]){
|
|||
if(startupOnly){
|
||||
await sleep(12000);
|
||||
const state=await client.evaluate("({ready:document.body?.dataset?.ready||null,version:document.querySelector('.brand small')?.textContent||null,boards:typeof data!=='undefined'?Object.keys(data.metas).length:null,origin:typeof data!=='undefined'&&Boolean(data.metas?.B0?.puzzle),worldGeneration:typeof data!=='undefined'?data.worldGeneration:null,turnFont:getComputedStyle(document.querySelector('.board-svg text')||document.body).fontFamily,status:document.querySelector('#statusMessage')?.textContent||'',text:document.body?.innerText?.slice(0,300)||''})");
|
||||
assert(state.ready==='true'&&state.version==='v47.77'&&state.boards===1&&state.origin&&state.worldGeneration==='v47-field-reset-20260728-interaction-fix',`Real-browser startup state is incomplete: ${JSON.stringify(state)}`);
|
||||
assert(state.ready==='true'&&state.version==='v47.83'&&state.boards===1&&state.origin&&state.worldGeneration==='v47-field-reset-20260728-interaction-fix',`Real-browser startup state is incomplete: ${JSON.stringify(state)}`);
|
||||
assert(/DotGothic16|Press Start 2P|MS Gothic|monospace/i.test(state.turnFont),'Dot-styled game font is not active in the browser');
|
||||
console.log(`Real-browser startup passed: ${JSON.stringify(state)}`);return;
|
||||
}
|
||||
|
|
@ -590,7 +594,7 @@ async function main(runProfiles=profiles,cpuRates=[1,4]){
|
|||
assert(displayCadence.count>=60&&displayCadence.p50<=20,`Headless display baseline is not 60 Hz: ${JSON.stringify(displayCadence)}`);
|
||||
const gameplayBudgets=await measureGameplaySimplificationBudgets(client);
|
||||
const cursorModes=await measureCursorModes(client);
|
||||
assert(cursorModes.defaultMode==='default'&&cursorModes.emojiMode==='native'&&cursorModes.flagMode==='native'&&cursorModes.domMode==='dom'&&cursorModes.visibleBeforeDrag&&cursorModes.visibleDuringDrag&&cursorModes.movedDuringDrag,`Cursor mode runtime probe failed: ${JSON.stringify(cursorModes)}`);
|
||||
assert(cursorModes.defaultMode==='default'&&cursorModes.emojiMode==='dom'&&cursorModes.flagMode==='dom'&&cursorModes.domMode==='dom'&&cursorModes.visibleBeforeDrag&&!cursorModes.visibleDuringDrag&&!cursorModes.movedDuringDrag,`Cursor mode runtime probe failed: ${JSON.stringify(cursorModes)}`);
|
||||
const cursorCadence=await measureCursorCadence(client);
|
||||
console.log(`Display cadence | median ${displayCadence.p50.toFixed(2)} ms | p95 ${displayCadence.p95.toFixed(2)} ms`);
|
||||
console.log(`Gameplay budgets | snap ${gameplayBudgets.snap.p95.toFixed(3)} ms | pointer samples ${gameplayBudgets.pointerSamples.p95.toFixed(3)} ms | minimap ${gameplayBudgets.minimap.p95.toFixed(3)} ms | noise max ${gameplayBudgets.noise.max.toFixed(3)} ms | worker ${gameplayBudgets.workerElapsed.toFixed(1)} ms`);
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ assert(!app.includes('invalidateFieldOverlay')&&!app.includes('invalidateStoreEf
|
|||
assert.equal(catalog.length>0,true);assert.equal(catalog.filter(item=>item.id==='score-lens').length,1);
|
||||
|
||||
// Clicking a board must select/input only; camera navigation is explicit elsewhere.
|
||||
assert(functionSource('shouldTeleportToUnsolvedBoard').includes('return false'));
|
||||
assert(!app.includes('shouldTeleportToUnsolvedBoard'));
|
||||
assert(!functionSource('bindBoard').includes('centerMeta('));
|
||||
assert(!app.includes('function promoteStaticBoard('));
|
||||
assert(functionSource('makeStaticBoard').includes('card.tabIndex=-1')&&!functionSource('makeStaticBoard').includes("setAttribute('role','button')"));
|
||||
assert(!app.includes('makeStaticBoard')&&!app.includes('board-static'));
|
||||
|
||||
// Every hydrated puzzle in the visible field remains fully detailed.
|
||||
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)'));
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ function persistenceContext({localMeta=null,localState=null,existingMeta=null,ex
|
|||
fieldBoundsFromMetas:()=>({minX:0,minY:0,maxX:1,maxY:1}),SAVE_SCHEMA:31,WORLD_GENERATION:'current-only-test',
|
||||
clearRecoveryJournalIfCovered:()=>{},broadcastWorldSignal:()=>{},scheduleCloudPush:()=>{},
|
||||
refreshWorldView:()=>{context.refreshes++},refreshes:0,
|
||||
interactionActive:()=>false,waitForInteractionSettle:async()=>{},
|
||||
perfStart:()=>0,perfEnd:()=>{},perfGauge:()=>{}
|
||||
};
|
||||
vm.createContext(context);
|
||||
|
|
@ -153,8 +154,10 @@ async function verifyPersistenceConflicts(){
|
|||
}
|
||||
|
||||
function verifyGlobalMerge(){
|
||||
const {normalizeSpecialMechanics}=require('../shared-contracts');
|
||||
const context={
|
||||
deepClone:value=>structuredClone(value),structuredClone,
|
||||
normalizeSpecialMechanics,
|
||||
validWorldEpoch:value=>typeof value==='string'&&value.startsWith('world:'),
|
||||
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0)||String(a?.revAuthor||'').localeCompare(String(b?.revAuthor||'')),
|
||||
bonusEventTotal:events=>Object.values(events||{}).reduce((sum,value)=>sum+(value||0),0)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ context.addMetaToOccupancy=meta=>{for(const[dx,dy]of meta.chunks)context.occupan
|
|||
context.refreshWorldView=options=>{calls.refresh++;if(options?.syncConnections!==false)context.syncBoundaryConnections();context.renderAll();context.updateHud();if(options?.hide)context.hideStatus();return options?.persist?context.save(options.immediate):true};
|
||||
context.rebuildOccupancy=()=>{context.occupancy=new Map();context.closedVoidKeys=new Set();for(const meta of Object.values(context.data.metas))for(const[dx,dy]of meta.chunks)context.occupancy.set(context.key2(meta.x+dx,meta.y+dy),meta.id);const candidates=new Set();for(const key of context.occupancy.keys()){const[x,y]=key.split(',').map(Number);for(const[dr,dc]of Object.values(context.SIDE_D))candidates.add(context.key2(x+dc,y+dr))}for(const key of candidates){const[x,y]=key.split(',').map(Number);if(!context.occupancy.has(key)&&[...Object.values(context.SIDE_D)].every(([dr,dc])=>context.occupancy.has(context.key2(x+dc,y+dr))))context.closedVoidKeys.add(key)}};
|
||||
context.metaState=id=>context.data.states[id]||(context.data.states[id]={solved:false,expanded:false,paths:[],specialProgress:{crossings:[]},rev:0});
|
||||
context.ensureMetaState=id=>context.metaState(id);
|
||||
context.unsolvedBoardCount=()=>Object.keys(context.data.metas).filter(id=>!context.metaState(id).solved).length;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(`
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ async function decode(blob,module=persistence){
|
|||
if(typeof CompressionStream==='function'&&typeof DecompressionStream==='function'){const compressed=await encode('gzip'),damaged=Buffer.from(compressed.bytes);damaged[Math.max(10,Math.floor(damaged.length/2))]^=0xff;let gzipRejected=false;try{await decode(new Blob([damaged]))}catch(_){gzipRejected=true}assert(gzipRejected,'Corrupt gzip archive was accepted')}
|
||||
const worker=read('field-persistence-worker.js'),moduleSource=read('field-persistence.js');
|
||||
class FakeWorker{
|
||||
constructor(){this.onmessage=null;this.onerror=null;const owner=this,context={TextEncoder,Uint8Array,Uint32Array,JSON,Error,self:{postMessage(data){queueMicrotask(()=>owner.onmessage?.({data}))}}};vm.createContext(context);vm.runInContext(worker,context);this.workerSelf=context.self}
|
||||
constructor(){this.onmessage=null;this.onerror=null;const owner=this,archiveCodec=require('../archive-codec'),context={TextEncoder,Uint8Array,Uint32Array,JSON,Error,importScripts:()=>{},self:{BendArchiveCodec:archiveCodec,postMessage(data){queueMicrotask(()=>owner.onmessage?.({data}))}}};vm.createContext(context);vm.runInContext(worker,context);this.workerSelf=context.self}
|
||||
postMessage(data){queueMicrotask(()=>{try{this.workerSelf.onmessage({data:structuredClone(data)})}catch(error){this.onerror?.({message:error.message})}})}
|
||||
terminate(){}
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ async function decode(blob,module=persistence){
|
|||
const workerEncoded=await encode('identity',workerPersistence),workerDecoded=await decode(workerEncoded.blob,workerPersistence);
|
||||
assert(workerEncoded.result.worker===true&&workerDecoded.result.worker===true&&workerDecoded.seen.length===2,'Archive worker execution path did not round-trip records');
|
||||
delete global.Worker;global.BendFieldPersistence=persistence;
|
||||
assert(worker.includes('MAX_CHUNK_BYTES=1024*1024')&&worker.includes('postMessage({id,chunks,rawBytes,crc32:crc32Hex()},chunks)'),'Archive worker does not enforce transferable 1 MiB output chunks');
|
||||
assert(moduleSource.includes("new Worker('field-persistence-worker.js?v=47.77')")&&moduleSource.includes('item.byteLength>MAX_ARCHIVE_LINE_BYTES')&&moduleSource.includes('WORKER_TARGET_BYTES=1024*1024')&&moduleSource.includes('pendingLineBytes'),'Archive worker path, 1 MiB batching, or worker line limit is missing');
|
||||
assert(worker.includes("importScripts('archive-codec.js')")&&worker.includes('MAX_CHUNK_BYTES=1024*1024')&&worker.includes('postMessage({id,chunks,rawBytes,crc32:crc32Hex(crc)},chunks)'),'Archive worker does not share the codec or enforce transferable 1 MiB output chunks');
|
||||
assert(moduleSource.includes("new Worker('field-persistence-worker.js')")&&moduleSource.includes('item.byteLength>MAX_ARCHIVE_LINE_BYTES')&&moduleSource.includes('WORKER_TARGET_BYTES=1024*1024')&&moduleSource.includes('pendingLineBytes'),'Archive worker path, 1 MiB batching, or worker line limit is missing');
|
||||
console.log('Field archive round-trip and corruption checks passed');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1});
|
||||
|
|
|
|||
56
test/frame-drag-scheduler-test.js
Normal file
56
test/frame-drag-scheduler-test.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
'use strict';
|
||||
|
||||
const {assert}=require('./helpers/app-source');
|
||||
const {createFrameScheduler}=require('../client/input/frame-scheduler');
|
||||
const {createDragScheduler,STATES}=require('../client/input/drag');
|
||||
|
||||
function fakeClock(){
|
||||
let now=0,nextId=1;
|
||||
const frames=new Map(),timers=new Map();
|
||||
return{
|
||||
now:()=>now,
|
||||
requestFrame:callback=>{const id=nextId++;frames.set(id,callback);return id},
|
||||
cancelFrame:id=>frames.delete(id),
|
||||
setDelay:(callback,delay)=>{const id=nextId++;timers.set(id,{callback,at:now+delay});return id},
|
||||
clearDelay:id=>timers.delete(id),
|
||||
advance(milliseconds){
|
||||
now+=milliseconds;
|
||||
for(const[id,entry]of[...timers])if(entry.at<=now){timers.delete(id);entry.callback()}
|
||||
const pending=[...frames];frames.clear();for(const[,callback]of pending)callback(now);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const clock=fakeClock(),commits=[];
|
||||
const scheduler=createFrameScheduler({
|
||||
requestFrame:clock.requestFrame,cancelFrame:clock.cancelFrame,setDelay:clock.setDelay,clearDelay:clock.clearDelay,
|
||||
now:clock.now,interval:1000/60,tolerance:1.25,watchdogDelay:18,
|
||||
commit:(value,timestamp)=>commits.push({value,timestamp})
|
||||
});
|
||||
for(let index=0;index<40;index++){scheduler.push(index);clock.advance(2)}
|
||||
clock.advance(20);
|
||||
assert(commits.length<=6,'High-rate input produced more than one presentation commit per nominal 60 Hz interval');
|
||||
assert(commits.at(-1).value===39,'Latest-value frame scheduling lost the newest input');
|
||||
for(let index=1;index<commits.length;index++)assert(commits[index].timestamp-commits[index-1].timestamp>=15,'Presentation commits exceeded the 60 Hz tolerance');
|
||||
|
||||
const dragClock=fakeClock(),states=[];
|
||||
const drag=createDragScheduler({
|
||||
requestFrame:dragClock.requestFrame,cancelFrame:dragClock.cancelFrame,setDelay:dragClock.setDelay,clearDelay:dragClock.clearDelay,
|
||||
now:dragClock.now,interval:1000/60,tolerance:1.25,watchdogDelay:18,maxSamples:4,
|
||||
trim:samples=>{while(samples.length>4)samples.splice(1,1)},
|
||||
onFrame:session=>states.push(session.inspect().state)
|
||||
});
|
||||
drag.arm(7);
|
||||
assert(drag.inspect().state==='armed','Drag did not enter armed state');
|
||||
for(let index=0;index<8;index++)drag.push({pointerId:7,clientX:index,clientY:index});
|
||||
assert(drag.inspect().state==='running'&&drag.inspect().logicalCount===4&&drag.latest().clientX===7,'Drag scheduler did not separate bounded logical samples from latest visual input');
|
||||
dragClock.advance(17);
|
||||
assert(states[0]==='running','Drag scheduler did not commit through its running frame lane');
|
||||
assert(drag.beginDrain({pointerId:7,clientX:8,clientY:8})&&drag.inspect().state==='draining','Drag did not enter draining state');
|
||||
assert(drag.drainLogical().at(-1).clientX===8,'Release drain lost final input intent');
|
||||
drag.beginSettling();assert(drag.inspect().state==='settling','Drag did not enter settling state');
|
||||
drag.settle();assert(drag.inspect().state==='idle','Settled drag did not return to idle');
|
||||
drag.arm(9);drag.cancel();assert(drag.inspect().state==='cancelled','Cancelled drag did not expose its terminal state');
|
||||
assert(STATES.join(',')==='idle,armed,running,draining,settling,cancelled','Drag lifecycle states changed unexpectedly');
|
||||
|
||||
console.log('Shared 60 Hz frame scheduler and explicit drag lifecycle behavior passed');
|
||||
|
|
@ -28,7 +28,7 @@ assert(functionSource('makeBoard').includes('boardCellsPath')&&!functionSource('
|
|||
for(const marker of ['rawPointerPosition','currentConfirmedCell','candidateDirection','currentCandidateCell','renderedHandlePosition'])assert(functionSource('drawingForPath').includes(marker),`Pointer state is missing ${marker}`);
|
||||
assert(app.includes('POINTER_SNAP_THRESHOLD=CELL*.45')&&!app.includes('POINTER_SNAP_RELEASE')&&!app.includes('hysteresisDragPoint'),'Single-threshold pointer snapping or hysteresis removal is incomplete');
|
||||
assert(app.includes('POINTER_DOMINANT_RATIO=1.25')&&!app.includes('POINTER_BUFFER_MS')&&!app.includes('consumeBufferedPointerTurn'),'Speculative one-step turn extension remains active');
|
||||
assert(functionSource('pointerEventSamples').includes("pointerType!=='mouse'")&&functionSource('processBoardDragFrame').includes('const move=b.pendingPointerMove'),'Ordered pointer samples are not preserved through a drag frame');
|
||||
assert(functionSource('pointerEventSamples').includes("pointerType!=='mouse'")&&functionSource('processBoardDragFrame').includes('scheduler.latest()'),'Ordered pointer samples are not preserved through a drag frame');
|
||||
assert(!functionSource('extendOne').includes('toast(')&&functionSource('extendOne').includes("if(lock&&!pathHasLockKey(path,lock))return false"),'Invalid direction still produces error feedback');
|
||||
assert(functionSource('openTipMergePlan').includes('tipDistance!==0')&&!app.includes('function pickupJoinStepsAtPoint'),'Adjacent path pickups can still auto-connect');
|
||||
assert(functionSource('openTipMergePlan').includes('seen.has(key)')&&functionSource('joinTips').includes('cancelBoardDragFrame(b)')&&functionSource('joinTips').includes('safeRelease(b.svg,pointerId)'),'Pickup merging can duplicate an overlapping route or retain pointer work after joining');
|
||||
|
|
@ -81,9 +81,9 @@ assert(!html.includes('id="teleportBtn"')&&html.includes('id="minimapOriginBtn"'
|
|||
assert(functionSource('centerRandomBoard').includes('getRandomValues')&&!app.includes('centerRandomUnsolved'),'Random minimap teleport still cycles through unsolved boards');
|
||||
assert(functionSource('beginMinimapPointer').includes('centerWorldUnit')&&functionSource('moveMinimapPointer').includes('centerWorldUnit')&&html.includes('id="minimapCanvas" width="210" height="132" role="application" tabindex="0"'),'Direct/keyboard minimap interaction is missing');
|
||||
assert(functionSource('centerWorldUnit').includes('worldNavigationBounds'),'Minimap teleport is not clamped to world navigation bounds');
|
||||
assert(functionSource('inWorldOverview').includes('cam.scale<=OVERVIEW_ZOOM_THRESHOLD')&&!app.includes('OVERVIEW_HYSTERESIS')&&css.includes('.static-summary-fill{fill:transparent!important'),'Zoomed board summaries retain sticky green/gray squares');
|
||||
assert(functionSource('inWorldOverview').includes('cam.scale<=OVERVIEW_ZOOM_THRESHOLD')&&!app.includes('OVERVIEW_HYSTERESIS')&&!app.includes('makeStaticBoard')&&!css.includes('.static-summary'),'Retired zoomed board summaries remain active');
|
||||
assert(!functionSource('drawWorldOverview').includes('drawWorldOverviewPaths')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines'),'Far zoom does not use the minimap long-line renderer');
|
||||
assert(functionSource('makeStaticBoard').includes('renderedConnectedLineWidth(meta,index)')&&!functionSource('makeStaticBoard').includes('state.solved')&&css.includes('.static-summary-path{')&&css.includes('vector-effect:non-scaling-stroke;opacity:.9'),'Line thickness disappears from an unsolved zoomed-in board');
|
||||
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&functionSource('renderBoardNow').includes('renderedConnectedLineWidth'),'Visible detailed boards do not preserve connected-line thickness');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!functionSource('rebuildWorldOverviewCache').includes('storeCellForMeta'),'Zoomed-out view no longer matches the minimap renderer');
|
||||
assert(functionSource('leftFieldPanAllowed').includes("classList?.contains('solved')")&&functionSource('beginPan').includes('leftFieldPanAllowed(e)'),'Solved/undiscovered left-drag field panning is missing');
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ assert(functionSource('evictHydratedBoardDetails').includes('hydratedBoardLru.si
|
|||
|
||||
// 13-14. Contextual HUD and active-board isolation.
|
||||
assert(css.includes('#topbar.drawing-active')&&css.includes(':focus-within')&&css.includes('transition:opacity'),'Contextual HUD fading constraints are missing');
|
||||
assert(!css.includes('.board-card:not(.input-active) .static-layer')&&!css.includes('filter:saturate')&&!css.includes('backdrop-filter')&&!css.includes('drop-shadow'),'Non-selected boards are dimmed or global presentation still creates costly filter surfaces');
|
||||
assert(!css.includes('.board-card:not(.input-active) .static-layer')&&!css.includes('filter:saturate')&&!css.includes('backdrop-filter')&&!css.includes('.board-card{filter:drop-shadow'),'Non-selected boards are dimmed or global presentation still creates costly filter surfaces');
|
||||
|
||||
// 15. Skippable completion independent of persistence.
|
||||
const solveSource=functionSource('checkSolvedAndExpand');
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ const path=require('path');
|
|||
const vm=require('vm');
|
||||
const root=path.resolve(__dirname,'../..');
|
||||
const read=name=>fs.readFileSync(path.join(root,name),'utf8');
|
||||
const app=read('app.js'),html=read('index.html'),css=read('style.css'),worker=read('puzzle-worker.js'),appLogicSource=read('app-logic.js');
|
||||
const app=read('app.js'),html=read('index.html'),css=`${read('style.css')}\n${read('client/styles/tokens.css')}\n${read('client/styles/base.css')}\n${read('client/ui/cursor.css')}\n${read('client/styles/accessibility.css')}`,worker=read('puzzle-worker.js'),appLogicSource=read('app-logic.js'),buildMetaSource=read('build-meta.js');
|
||||
const buildMeta=require(path.join(root,'build-meta.js'));
|
||||
function assert(value,message){if(!value)throw new Error(message)}
|
||||
function functionSource(name,source=app){
|
||||
let start=source.indexOf(`async function ${name}(`);if(start<0)start=source.indexOf(`function* ${name}(`);if(start<0)start=source.indexOf(`function ${name}(`);
|
||||
|
|
@ -43,4 +44,4 @@ function starterPuzzle(){
|
|||
assert(match,'Bundled origin puzzle missing');
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
module.exports={root,read,app,html,css,worker,appLogicSource,assert,functionSource,createContext,runFunctions,loadBendPuzzle,loadAppLogic,starterPuzzle,vm,path,fs};
|
||||
module.exports={root,read,app,html,css,worker,appLogicSource,buildMetaSource,buildMeta,assert,functionSource,createContext,runFunctions,loadBendPuzzle,loadAppLogic,starterPuzzle,vm,path,fs};
|
||||
|
|
|
|||
33
test/interaction-ownership-test.js
Normal file
33
test/interaction-ownership-test.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use strict';
|
||||
|
||||
const {assert,read}=require('./helpers/app-source');
|
||||
const {createGestureCoordinator}=require('../client/input/gesture-coordinator');
|
||||
const {createInteractionState}=require('../client/input/interaction-state');
|
||||
|
||||
const coordinator=createGestureCoordinator();
|
||||
assert(coordinator.claim(1,'pan'),'Pan did not claim an idle pointer');
|
||||
assert(!coordinator.claim(1,'wheel'),'Lower-precedence wheel input displaced pan ownership');
|
||||
assert(coordinator.claim(1,'reaction')&&coordinator.owner(1)==='reaction','Reaction did not take precedence over pan');
|
||||
assert(coordinator.claim(1,'draw')&&coordinator.owner(1)==='draw','Draw did not take precedence over reaction');
|
||||
assert(!coordinator.release(1,'pan')&&coordinator.owns(1,'draw'),'A non-owner released the drawing pointer');
|
||||
assert(coordinator.release(1,'draw')&&!coordinator.owner(1),'The drawing pointer did not return to idle');
|
||||
assert(coordinator.claim(2,'pan')&&coordinator.claim(3,'pan'),'Independent pointers could not be owned');
|
||||
assert(coordinator.claim(2,'pinch')&&coordinator.claim(3,'pinch'),'Pinch did not transfer both touch pointers');
|
||||
coordinator.cancelAll('blur');
|
||||
assert(!coordinator.owner(2)&&!coordinator.owner(3),'Blur cancellation leaked pointer ownership');
|
||||
|
||||
const interactions=createInteractionState();let notifications=0;
|
||||
const unsubscribe=interactions.subscribe(()=>notifications++);
|
||||
interactions.set('camera','pan:1');
|
||||
assert(interactions.active('camera')&&interactions.active('world')&&!interactions.active('worker'),'Camera interaction scopes are incorrect');
|
||||
interactions.set('drawing','draw:2');
|
||||
assert(interactions.active('worker')&&interactions.active('persistence'),'Drawing interaction scopes are incorrect');
|
||||
interactions.clear('camera');interactions.clear('drawing');unsubscribe();
|
||||
assert(!interactions.active()&¬ifications===4,'Interaction state did not settle or notify exactly once per transition');
|
||||
|
||||
const app=read('app.js'),html=read('index.html');
|
||||
assert(html.indexOf('client/input/frame-scheduler.js')<html.indexOf('client/input/drag.js')&&html.indexOf('client/input/drag.js')<html.indexOf('app.js')&&html.indexOf('client/input/interaction-state.js')<html.indexOf('app.js')&&html.indexOf('client/input/gesture-coordinator.js')<html.indexOf('app.js'),'Interaction ownership modules are not loaded before the application');
|
||||
const bodyReads=[...app.matchAll(/classList(?:\?\.)?\.contains(?:\?\.)?\(['"]is-interacting['"]\)/g)];
|
||||
assert(bodyReads.length===1&&app.includes("document.body.classList.toggle('is-interacting',active)"),'The DOM interaction class is still used as application state');
|
||||
|
||||
console.log('Gesture ownership and scoped interaction-state behavior passed');
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
'use strict';
|
||||
const {vm,assert,functionSource,loadAppLogic,app,css}=require('./helpers/app-source');
|
||||
const {createFrameScheduler}=require('../client/input/frame-scheduler');
|
||||
const AppLogic=loadAppLogic();
|
||||
const context={PAD:16,CELL:40,ckey:(r,c)=>`${r},${c}`};
|
||||
vm.createContext(context);
|
||||
|
|
@ -38,15 +39,16 @@ const centerVelocity=gestureContext.gesture.edgePanVelocity(500,400),rightVeloci
|
|||
assert(centerVelocity[0]===0&¢erVelocity[1]===0&&rightVelocity[0]>0,'Edge auto-pan velocity is not limited to the viewport edge');
|
||||
console.log('Direct snap threshold, outward gate reach, and edge auto-pan test passed');
|
||||
|
||||
const sampleContext={perfNow:()=>100,DRAG_MAX_POINTER_SAMPLES:12};
|
||||
let capturedPointerSample=null;
|
||||
const sampleContext={perfNow:()=>100,DRAG_MAX_POINTER_SAMPLES:12,ensureBoardDragScheduler:()=>({push:sample=>{capturedPointerSample=sample;return 1}})};
|
||||
vm.createContext(sampleContext);
|
||||
vm.runInContext(`${functionSource('pointerEventSamples')}\n${functionSource('trimBoardPointerSamples')}\n${functionSource('setBoardPointerSample')}\n${functionSource('appendBoardPointerSamples')}\nthis.pointer={pointerEventSamples,appendBoardPointerSamples};`,sampleContext);
|
||||
const cornerSamples=[
|
||||
{clientX:220,clientY:280},{clientX:140,clientY:280},{clientX:140,clientY:196},
|
||||
{clientX:290,clientY:196},{clientX:290,clientY:250}
|
||||
],sampleBoard={pendingPointerMove:null};
|
||||
],sampleBoard={};
|
||||
sampleContext.pointer.appendBoardPointerSamples(sampleBoard,{pointerId:12,pointerType:'pen',getCoalescedEvents:()=>cornerSamples});
|
||||
assert(JSON.stringify([sampleBoard.pendingPointerMove.clientX,sampleBoard.pendingPointerMove.clientY])===JSON.stringify([290,250])&&sampleBoard.pointerMoveSamples.length===1,'Pointer coalescing did not retain the newest sample in the bounded logic queue');
|
||||
assert(JSON.stringify([capturedPointerSample.clientX,capturedPointerSample.clientY])===JSON.stringify([290,250]),'Pointer coalescing did not retain the newest sample in the bounded logic queue');
|
||||
const threeTurnPath={startGate:0,endGate:null,cells:[[4,6],[4,5],[4,4],[4,3],[4,2],[4,1],[3,1],[2,1],[2,2],[2,3],[3,3]]};
|
||||
assert(AppLogic.analyzePathTurns(threeTurnPath,[[4,6,'E']],[],false).count===3,'The pictured left-up-right-down pickup gesture does not display three turns');
|
||||
assert(!functionSource('extendPointerTo').includes('consumeBufferedPointerTurn'),'Pickup drag still extends a speculative second cell');
|
||||
|
|
@ -83,7 +85,7 @@ const mergeContext={
|
|||
metaState:()=>mergeState,manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]),ckey:(r,c)=>`${r},${c}`,cellSet:p=>p.validSet,
|
||||
cancelBoardDragFrame:()=>cancelledMergeFrames++,
|
||||
applyBoardCommand:(_board,mutate)=>mutate()===false?false:true,
|
||||
safeRelease:()=>releasedMergePointers++,playSound:()=>{},queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
|
||||
safeRelease:()=>releasedMergePointers++,commitConnectedLineVisuals:()=>{},playSound:()=>{},queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
|
||||
};
|
||||
vm.createContext(mergeContext);
|
||||
vm.runInContext(`${functionSource('openTipMergePlan')}\n${functionSource('joinTips')}\nthis.joinTips=joinTips;`,mergeContext);
|
||||
|
|
@ -96,15 +98,20 @@ assert(!app.includes('function pickupJoinStepsAtPoint')&&!app.includes('function
|
|||
let cameraCallback=null,cameraTimerCallback=null,cameraApplies=0;
|
||||
const cameraContext={
|
||||
CAMERA_DISPLAY_WATCHDOG_MS:18,DRAG_FRAME_INTERVAL:1000/60,
|
||||
cam:{x:0,y:0,scale:1},cameraInteractionFrame:0,cameraInteractionDelayTimer:0,cameraInteractionLastDraw:0,pendingCameraInteraction:null,
|
||||
cam:{x:0,y:0,scale:1},pendingCameraInteraction:null,
|
||||
requestAnimationFrame:callback=>{cameraCallback=callback;return 7},cancelAnimationFrame:()=>{},setTimeout:callback=>{cameraTimerCallback=callback;return 8},clearTimeout:()=>{},applyCamera:immediate=>{assert(immediate===true,'Queued camera update was not committed directly')},
|
||||
perfNow:()=>100,perfStart:()=>0,perfEnd:()=>0,perfCount:()=>{},recordInteractionCommit:()=>{},observeInteractionFrame:()=>{},data:{cameraAnchor:null},currentCameraAnchor:()=>({centerX:0,centerY:0,scale:1}),markGlobalDirty:()=>{},
|
||||
};
|
||||
vm.createContext(cameraContext);
|
||||
vm.runInContext(`${functionSource('commitCameraInteraction')}\n${functionSource('queueCameraInteraction')}\nthis.queueCameraInteraction=queueCameraInteraction;`,cameraContext);
|
||||
vm.runInContext(`${functionSource('commitCameraInteraction')}\nthis.commitCameraInteraction=commitCameraInteraction;`,cameraContext);
|
||||
cameraContext.cameraInteractionScheduler=createFrameScheduler({
|
||||
requestFrame:cameraContext.requestAnimationFrame,cancelFrame:cameraContext.cancelAnimationFrame,setDelay:cameraContext.setTimeout,clearDelay:cameraContext.clearTimeout,
|
||||
now:cameraContext.perfNow,interval:1000/60,tolerance:1.25,watchdogDelay:18,commit:(next,timestamp)=>cameraContext.commitCameraInteraction(next,timestamp)
|
||||
});
|
||||
vm.runInContext(`${functionSource('queueCameraInteraction')}\nthis.queueCameraInteraction=queueCameraInteraction;`,cameraContext);
|
||||
cameraContext.applyCamera=()=>cameraApplies++;
|
||||
cameraContext.queueCameraInteraction({x:10,y:20,scale:1});cameraContext.queueCameraInteraction({x:30,y:40,scale:1});
|
||||
assert(cameraContext.cameraInteractionFrame===7&&cameraContext.cam.x===0,'Raw pan events were applied before the animation frame');
|
||||
assert(cameraContext.cameraInteractionScheduler.inspect().armed&&cameraContext.cam.x===0,'Raw pan events were applied before the animation frame');
|
||||
cameraCallback();
|
||||
assert(cameraContext.cam.x===30&&cameraContext.cam.y===40&&cameraApplies===1,'Pan events were not coalesced to the latest frame');
|
||||
cameraTimerCallback();
|
||||
|
|
@ -155,15 +162,17 @@ const shopItems=[{id:'O1'},{id:'O2'},...Array.from({length:12},(_,index)=>({id:`
|
|||
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('renderStorePanel').includes("'store-cursor':'store-other'"),'Shop rendering bypasses the fixed 2+12 item inventory');
|
||||
const storeRateContext={
|
||||
STORE_CHANCE:.10,hash32:value=>value>>>0,LOCAL_SOLVER:'tester',STORE_PRICE_VERSION:1,SCORE_VERSION:3,
|
||||
trustedNow:()=>1000,currentPlayerName:()=>"tester",seededStoreItemIds:()=>shopItems.map(item=>item.id),storePriceDetails:()=>({coefficient:1})
|
||||
trustedNow:()=>1000,currentPlayerName:()=>"tester",seededStoreItemIds:()=>shopItems.map(item=>item.id),storePriceDetails:()=>({coefficient:1}),puzzleOf:meta=>meta.puzzle
|
||||
};
|
||||
vm.createContext(storeRateContext);
|
||||
vm.runInContext(`${functionSource('maybeOpenStore')}\nthis.maybeOpenStore=maybeOpenStore;`,storeRateContext);
|
||||
vm.runInContext(`${functionSource('storeObstacleCell')}\n${functionSource('maybeOpenStore')}\nthis.maybeOpenStore=maybeOpenStore;`,storeRateContext);
|
||||
const eligibleStoreState=()=>({solved:true,store:null,paths:[{cells:[[0,0],[0,1]]}]});
|
||||
const storeMeta={seed:1,puzzle:{obstacles:[[1,1],[2,2]]}};
|
||||
const belowThreshold=eligibleStoreState(),atThreshold=eligibleStoreState();
|
||||
storeRateContext.maybeOpenStore({seed:1},belowThreshold,0,.099999);
|
||||
storeRateContext.maybeOpenStore({seed:1},atThreshold,0,.10);
|
||||
storeRateContext.maybeOpenStore(storeMeta,belowThreshold,0,.099999);
|
||||
storeRateContext.maybeOpenStore(storeMeta,atThreshold,0,.10);
|
||||
assert(belowThreshold.store&&atThreshold.store===null,'Shop appearance boundary is not exactly 1/10');
|
||||
assert(storeMeta.puzzle.obstacles.some(cell=>cell[0]===belowThreshold.store.cell[0]&&cell[1]===belowThreshold.store.cell[1]),'Shop did not replace an obstacle cell');
|
||||
|
||||
assert(!app.includes('function overviewShopAtClient(')&&!functionSource('beginPan').includes('overviewShopAtClient'),'Zoomed-out view still has a shop-only hit target absent from the minimap');
|
||||
|
||||
|
|
@ -232,7 +241,8 @@ const reconnectContext={
|
|||
metaState:()=>reconnectState,usedGateSet:()=>new Set(),LINE_COLORS:['#0af','#fa0','#0f0'],canonicalGateColorIndex:()=>1,
|
||||
applyBoardCommand:(_board,mutate)=>{mutate();return true},neighborColor:()=>null,playSound:()=>{},
|
||||
usesLightweightDragOverlay:()=>false,requestAnimationFrame:callback=>callback(),
|
||||
gateConnectEffect:(_board,_gate,color)=>gateEffects.push(color),queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
|
||||
gateConnectEffect:(_board,_gate,color)=>gateEffects.push(color),queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{},
|
||||
commitConnectedLineVisuals:()=>{},cancelBoardDragFrame:()=>{},clearDragRender:()=>{},hidePickupHandleOverlay:()=>{},safeRelease:()=>{}
|
||||
};
|
||||
vm.createContext(reconnectContext);
|
||||
vm.runInContext(`${functionSource('finalizeAtGate')}\nthis.finalizeAtGate=finalizeAtGate;`,reconnectContext);
|
||||
|
|
@ -279,7 +289,7 @@ console.log('Connected-gate dragging and two-handle line disappearance tests pas
|
|||
assert(!app.includes('RETRACTION_HOLD_MS')&&!app.includes('pendingRetraction'),'Own-line retraction still has a delayed state');
|
||||
assert(functionSource('extendOne').includes('path.cells.splice(rewind+1)')&&functionSource('extendPointerTo').includes('rewindActivePathToCell(b,targetCell'),'Original immediate own-line shortening was not restored');
|
||||
assert(functionSource('gateFromPointOrCell').includes('maxPixels:0')&&!functionSource('extendPointerTo').includes('gateConnectionSteps'),'A knob can still snap to a gate from an adjacent cell');
|
||||
assert(functionSource('shouldTeleportToUnsolvedBoard').includes('return false')&&!functionSource('bindBoard').includes('centerMeta(')&&!app.includes('function promoteStaticBoard('),'Board input can still promote a summary or teleport the camera');
|
||||
assert(!app.includes('shouldTeleportToUnsolvedBoard')&&!functionSource('bindBoard').includes('centerMeta(')&&!app.includes('function promoteStaticBoard('),'Board input can still promote a summary or teleport the camera');
|
||||
assert(functionSource('matchingNumberKeys').includes('partialTurnCount')&&functionSource('updateNumberMatchFeedback').includes("classList.toggle('number-match',current.size>0)")&&functionSource('updateNumberMatchFeedback').includes("matchOrbit.classList.toggle('show'")&&css.includes('@keyframes numberMatchOrbit')&&css.includes('stroke-dasharray:1.2 5.35'),'Matching turn-number feedback does not keep an enlarged knob with a rotating perforated orbit');
|
||||
assert(functionSource('renderBoardNow').includes("'\\u66f2\\u304c\\u308b'")&&app.includes('numberLayer.append(node,warning)')&&css.includes('.number-turn-warning.show{opacity:1}'),'The 曲がる warning is not prominent or is behind the number');
|
||||
console.log('Immediate retraction, exact-cell gate, no-click-teleport, and number-match feedback guards passed');
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ assert(minimapBuildSource.includes('minimapCache={revision:minimapWorldRevision'
|
|||
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');
|
||||
assert(css.includes('#noiseCanvas.interaction-muted')&&css.includes('body.reduced-effects #noiseCanvas')&&functionSource('scheduleNoiseBackground').includes('noisePainted')&&!functionSource('scheduleNoiseBackground').includes('setTimeout'),'Decorative noise is not static or interaction/reduced-motion suppression is missing');
|
||||
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');
|
||||
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');
|
||||
|
|
@ -113,7 +113,7 @@ const mapContext2d={setTransform(){},clearRect(){},drawImage(){drawCopies++},beg
|
|||
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}),
|
||||
perfStart:()=>0,perfEnd:()=>0,perfCount:()=>{}
|
||||
interactionActive:()=>false,perfStart:()=>0,perfEnd:()=>0,perfCount:()=>{}
|
||||
};
|
||||
minimapContext.rebuildMinimapWorld=(width,height,dpr,x,y)=>{
|
||||
minimapBuilds++;minimapContext.minimapCache={...minimapContext.minimapCache,revision:minimapContext.minimapWorldRevision,width,height,dpr,anchorX:x,anchorY:y};
|
||||
|
|
@ -124,28 +124,26 @@ minimapContext.drawMinimap();assert(minimapBuilds===0&&drawCopies===1,'Small cam
|
|||
center=[20,0];minimapContext.drawMinimap();assert(minimapBuilds===1&&drawCopies===2,'Camera movement beyond minimap overscan did not rebuild exactly once');
|
||||
|
||||
let overviewLodSchedules=0;
|
||||
const overviewRendered=new Map(Array.from({length:6},(_,index)=>[`D${index}`,{id:`D${index}`,drawing:null}])),
|
||||
overviewStatic=new Map(Array.from({length:6},(_,index)=>[`S${index}`,{id:`S${index}`}]));
|
||||
const overviewRendered=new Map(Array.from({length:6},(_,index)=>[`D${index}`,{id:`D${index}`,drawing:null}]));
|
||||
const overviewLodContext={
|
||||
LOD_CHANGES_PER_PASS:4,rendered:overviewRendered,staticRendered:overviewStatic,inWorldOverview:()=>true,
|
||||
destroyBoard:board=>overviewRendered.delete(board.id),destroyStaticBoard:board=>overviewStatic.delete(board.id),
|
||||
LOD_CHANGES_PER_PASS:4,rendered:overviewRendered,inWorldOverview:()=>true,interactionActive:()=>false,
|
||||
destroyBoard:board=>overviewRendered.delete(board.id),
|
||||
scheduleLodPass:()=>overviewLodSchedules++,scheduleWorldOverview:()=>{},perfStart:()=>0,perfCount:()=>{},perfEnd:()=>{}
|
||||
};
|
||||
vm.createContext(overviewLodContext);vm.runInContext(`${ensureBoardsSource}\nthis.ensureBoards=ensureBoards;`,overviewLodContext);overviewLodContext.ensureBoards();
|
||||
assert(overviewRendered.size+overviewStatic.size===8&&overviewLodSchedules===1,'Overview eviction exceeded its four-change budget or failed to schedule continuation');
|
||||
assert(overviewRendered.size===2&&overviewLodSchedules===1,'Overview eviction exceeded its four-change budget or failed to schedule continuation');
|
||||
|
||||
let creationLodSchedules=0;
|
||||
const visibleIds=new Set(Array.from({length:10},(_,index)=>`B${index}`)),creationStatic=new Map(),
|
||||
const visibleIds=new Set(Array.from({length:10},(_,index)=>`B${index}`)),
|
||||
creationData={metas:Object.fromEntries([...visibleIds].map(id=>[id,{id,puzzle:{}}]))};
|
||||
const creationLodContext={
|
||||
LOD_CHANGES_PER_PASS:4,rendered:new Map(),staticRendered:creationStatic,data:creationData,inWorldOverview:()=>false,
|
||||
LOD_CHANGES_PER_PASS:4,rendered:new Map(),data:creationData,inWorldOverview:()=>false,interactionActive:()=>false,
|
||||
visibleMetaIds:()=>visibleIds,desiredInteractiveBoardIds:ids=>new Set(ids),metaState:()=>({solved:false}),
|
||||
makeStaticBoard:meta=>creationStatic.set(meta.id,{id:meta.id,signature:'current'}),destroyStaticBoard:board=>board&&creationStatic.delete(board.id),
|
||||
destroyBoard:()=>{},makeBoard:meta=>creationLodContext.rendered.set(meta.id,{id:meta.id,drawing:null}),staticBoardSignature:()=> 'current',
|
||||
destroyBoard:()=>{},makeBoard:meta=>creationLodContext.rendered.set(meta.id,{id:meta.id,drawing:null}),
|
||||
scheduleLodPass:()=>creationLodSchedules++,perfStart:()=>0,perfCount:()=>{},perfGauge:()=>{},perfEnd:()=>{}
|
||||
};
|
||||
vm.createContext(creationLodContext);vm.runInContext(`${ensureBoardsSource}\nthis.ensureBoards=ensureBoards;`,creationLodContext);creationLodContext.ensureBoards();
|
||||
assert(creationLodContext.rendered.size===4&&creationStatic.size===0&&creationLodSchedules===1,'Detailed board creation exceeded its four-change budget or hid visible boards behind static summaries');
|
||||
assert(creationLodContext.rendered.size===4&&creationLodSchedules===1,'Detailed board creation exceeded its four-change budget or hid visible boards');
|
||||
|
||||
(async()=>{
|
||||
let workerConstructions=0,workerNow=1000,timerId=0;
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ assert(css.includes('#presenceCanvas')&&css.includes('.board-claim-badge'),'Pres
|
|||
assert(app.includes('REALTIME_CURSOR_INTERVAL=50')&&app.includes('REALTIME_CURSOR_HEARTBEAT_INTERVAL=5000')&&app.includes('AUXILIARY_FRAME_INTERVAL'),'Cursor transport is not capped at 20 Hz or presence drawing is not tied to the auxiliary frame budget');
|
||||
assert(functionSource('scheduleRealtimeViewport').includes('REALTIME_VIEWPORT_INTERVAL'),'Viewport subscription is not throttled');
|
||||
assert(functionSource('drawPresenceLayer').includes('remotePlayers')&&functionSource('drawPresenceLayer').includes('scheduleMinimap'),'Remote cursors are not rendered through the shared canvas layer');
|
||||
assert(functionSource('drawMinimap').includes('nearbyPlayers'),'Remote players are missing from the minimap');
|
||||
assert(functionSource('drawMinimap').includes('remotePlayers'),'Remote players are missing from the minimap');
|
||||
assert(functionSource('requestBoardClaim').includes("type:'claim'")&&functionSource('touchBoardClaim').includes("type:'claim-touch'"),'Client claim lease messages are incomplete');
|
||||
assert(server.includes("status:423")&&server.includes('hasClaim(player.playerId,rawRow.id)'),'Server clear validation does not require the active claimant');
|
||||
assert(realtime.includes('5 * 60 * 1000')&&realtime.includes("releaseBoardClaim(boardId, 'moved')")&&realtime.includes("reason:'expired'"),'Five-minute lease or board-switch release behavior is missing');
|
||||
assert(realtime.includes("message.type === 'viewport'")&&realtime.includes("message.type === 'cursor-hide'")&&realtime.includes('pointInViewport'),'Realtime fan-out is not viewport-filtered');
|
||||
assert(!Object.keys(pkg.dependencies||{}).length&&!Object.keys(pkg.devDependencies||{}).length,'Phase 2 added an unnecessary runtime dependency');
|
||||
assert(!Object.keys(pkg.dependencies||{}).length&&Object.keys(pkg.devDependencies||{}).every(name=>name==='playwright-core'),'Phase 2 added an unnecessary runtime dependency');
|
||||
console.log('Shared-world phase 2 source guards passed');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
'use strict';
|
||||
const {vm,app,assert,functionSource}=require('./helpers/app-source');
|
||||
const {vm,app,assert,functionSource,buildMeta}=require('./helpers/app-source');
|
||||
const removed=[],deleted=[];
|
||||
const staleSessionJournal='bend-field:v30:v47-field-reset-20260727:journal:stale-session',retired=[
|
||||
{schema:31,generation:'v47-field-reset-20260728-bugfix'},
|
||||
|
|
@ -20,5 +20,5 @@ for(const item of retired){
|
|||
assert(deleted.includes(`${prefix}:world`),'Old IndexedDB was not deleted');
|
||||
}
|
||||
assert(removed.includes(staleSessionJournal),'Old per-session recovery journal was not removed');
|
||||
assert(app.includes("SAVE_SCHEMA=31,STORAGE_SCHEMA=30,IDB_LAYOUT_VERSION=8,FIELD_STORAGE_FORMAT=2,GAMEPLAY_DATA_VERSION=3,WORLD_GENERATION='v47-field-reset-20260728-interaction-fix'"),'v47.36 interaction-fix field reset generation is not active');
|
||||
assert(buildMeta.SAVE_SCHEMA===31&&buildMeta.STORAGE_SCHEMA===30&&buildMeta.IDB_LAYOUT_VERSION===8&&buildMeta.FIELD_STORAGE_FORMAT===2&&buildMeta.GAMEPLAY_DATA_VERSION===3&&buildMeta.WORLD_GENERATION==='v47-field-reset-20260728-interaction-fix','Interaction-fix field reset generation is not active');
|
||||
console.log('Full field reset test passed');
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ const path=require('path');
|
|||
const fs=require('fs');
|
||||
const {execFileSync}=require('child_process');
|
||||
const tests=[
|
||||
'source-smoke-test.js','v4771-ui-input-smoke-test.js','v4772-ownership-reaction-name-smoke-test.js',
|
||||
'v4774-drag-overview-smoke-test.js','v4775-pan-cursor-performance-smoke-test.js','v4776-frame-pipeline-smoke-test.js','v4777-hud-input-performance-smoke-test.js','v4778-interaction-scheduler-smoke-test.js','cleanup-performance-smoke-test.js','field-persistence-smoke-test.js','field-save-load-v2-smoke-test.js','gameplay-simplification-smoke-test.js','economy-simulation-test.js','performance-smoke-test.js','mirror-chunk-smoke-test.js','storage-smoke-test.js','save-pipeline-smoke-test.js','concurrency-smoke-test.js','stage34-smoke-test.js',
|
||||
'expansion-repair-smoke-test.js','expansion-smoke-test.js','interaction-smoke-test.js','anomaly-smoke-test.js','special-cell-smoke-test.js','reset-smoke-test.js','shared-world-client-smoke-test.js','phase2-source-smoke-test.js','realtime-lease-unit-test.js','realtime-phase2-smoke-test.js','server-smoke-test.js','shared-world-complete-smoke-test.js','security-authority-smoke-test.js'
|
||||
'shared-contracts-test.js','interaction-ownership-test.js','frame-drag-scheduler-test.js','architecture-boundaries-test.js','source-smoke-test.js','v4771-ui-input-smoke-test.js','v4772-ownership-reaction-name-smoke-test.js',
|
||||
'v4774-drag-overview-smoke-test.js','v4775-pan-cursor-performance-smoke-test.js','v4776-frame-pipeline-smoke-test.js','v4777-hud-input-performance-smoke-test.js','v4778-interaction-scheduler-smoke-test.js','v4779-settings-pan-hud-smoke-test.js','v4780-release-persistence-cursor-smoke-test.js','v4781-hud-gate-overlay-smoke-test.js','v4782-audio-highlight-store-internal-gate-smoke-test.js','v4783-map-store-economy-persistence-smoke-test.js','v4784-pan-solve-production-smoke-test.js','cleanup-performance-smoke-test.js','field-persistence-smoke-test.js','field-save-load-v2-smoke-test.js','gameplay-simplification-smoke-test.js','economy-simulation-test.js','performance-smoke-test.js','mirror-chunk-smoke-test.js','storage-smoke-test.js','save-pipeline-smoke-test.js','concurrency-smoke-test.js','stage34-smoke-test.js',
|
||||
'expansion-repair-smoke-test.js','expansion-smoke-test.js','interaction-smoke-test.js','anomaly-smoke-test.js','special-cell-smoke-test.js','reset-smoke-test.js','shared-world-client-smoke-test.js','phase2-source-smoke-test.js','realtime-lease-unit-test.js','realtime-phase2-smoke-test.js','server-recovery-test.js','server-smoke-test.js','shared-world-complete-smoke-test.js','security-authority-smoke-test.js'
|
||||
];
|
||||
for(const file of tests)execFileSync(process.execPath,[path.join(__dirname,file)],{stdio:'inherit'});
|
||||
const browserPath=process.env.BEND_FIELD_BROWSER_PATH||process.env.BEND_FIELD_EDGE_PATH||(process.platform==='win32'?'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe':'/usr/bin/chromium');
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const context={
|
|||
scheduleMirrorCheckpoint:()=>writes.checkpoint++,
|
||||
updateStorageRevision:()=>writes.revision++,clearRecoveryJournalIfCovered:(seq,covered)=>{writes.journalClear++;journalClears.push({seq,covered})},
|
||||
broadcastWorldSignal:()=>writes.signal++,scheduleCloudPush:()=>writes.cloud++,
|
||||
interactionActive:()=>false,waitForInteractionSettle:async()=>{},
|
||||
perfStart:()=>0,perfEnd:()=>0,perfGauge:()=>{},deepClone:value=>JSON.parse(JSON.stringify(value)),resetHistory:[],invalidateStoreEffectCache:()=>{},statsDirty:false
|
||||
};
|
||||
vm.createContext(context);
|
||||
|
|
@ -93,7 +94,7 @@ vm.runInContext(`${persistSource}\nthis.persistDirtyToDb=persistDirtyToDb;`,cont
|
|||
let scheduled=0,immediate=0;
|
||||
const saveContext={
|
||||
Promise,SAVE_DELAY:180,saveTimer:null,lifecyclePersistenceSuppressed:false,
|
||||
hasPendingPersistence:()=>false,persistNow:()=>{immediate++;return Promise.resolve(true)},
|
||||
hasPendingPersistence:()=>false,persistNow:()=>{immediate++;return Promise.resolve(true)},runDeferredSave:()=>{},
|
||||
setSaveStatus:()=>{},clearTimeout:()=>{},setTimeout:()=>{scheduled++;return 1}
|
||||
};
|
||||
vm.createContext(saveContext);vm.runInContext(`${saveSource}\nthis.save=save;`,saveContext);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ const {root,starterPuzzle}=require('./helpers/app-source');const {connectRealtim
|
|||
const port=32000+Math.floor(Math.random()*2000),dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-auth-')),child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',BEND_FIELD_DATA_DIR:dataDir},stdio:['ignore','pipe','pipe']});let stderr='',ws;child.stderr.on('data',c=>stderr+=c);const base=`http://127.0.0.1:${port}`,sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
async function req(url,opt={}){const response=await fetch(base+url,opt);return{response,body:await response.json()}}function auth(s){return{authorization:`Bearer ${s.playerId}.${s.token}`,'content-type':'application/json'}}
|
||||
function meta(id,x,seed,p){return{id,x,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed,axis:'MIX',sealedSides:[],puzzle:p,rev:1,revAuthor:'x'}}
|
||||
(async()=>{for(let i=0;i<100;i++){try{if((await req('/api/cloud/status')).response.ok)break}catch{}await sleep(30)}const alice=(await req('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body,p=starterPuzzle(),b0=meta('B0',0,15,p);
|
||||
(async()=>{for(let i=0;i<100;i++){try{if((await req('/api/cloud/status')).response.ok)break}catch{}await sleep(30)}const alice=(await req('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body,p=starterPuzzle(),route=[[0,1],[0,0],[1,0],[2,0],[3,0],[4,0],[4,1],[3,1],[2,1],[1,1],[1,2],[0,2],[0,3],[0,4],[1,4],[1,3],[2,3],[2,4],[3,4],[4,4],[4,3],[3,3],[3,2],[4,2]];p.g=[[0,1,'N'],[4,2,'S']];p.n=[[0,0,10]];p.valid=route.map(cell=>[...cell]);p.obstacles=[[2,2]];p.solution=[{startGate:0,endGate:1,cells:route.map(cell=>[...cell])}];p.specialCells={crossings:[],warps:[],locks:[],internalGates:[]};const b0=meta('B0',0,15,p);
|
||||
let r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{nextId:1},metas:[b0],states:[{id:'B0',value:{paths:[],solved:false}}]})});assert.equal(r.response.status,200);ws=await connectRealtime(base,alice);ws.send({type:'viewport',minX:-5,minY:-5,maxX:5,maxY:5});await ws.waitFor('snapshot');ws.send({type:'claim',requestId:'c',boardId:'B0'});assert.equal((await ws.waitFor(m=>m.type==='claim-result'&&m.requestId==='c')).ok,true);
|
||||
const solved={paths:p.solution.map(q=>({startGate:q.startGate,endGate:q.endGate,cells:q.cells})),solved:true,scoreAwarded:Number.MAX_SAFE_INTEGER,store:{pathIndex:0,cellIndex:0,itemIds:['level-min-10'],priceCoefficient:0,purchases:[]}};
|
||||
r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:solved}]})});assert.equal(r.response.status,200);const pulled=await req('/api/cloud/pull?since=0',{headers:auth(alice)}),state=pulled.body.page.states.B0;assert(state.scoreAwarded>0&&state.scoreAwarded<100000,'server must replace forged reward');assert(state.store&&state.store.priceCoefficient>=.8&&state.store.priceCoefficient<=1.2,'server must replace forged store pricing');assert.equal(state.store.itemIds.length,13);
|
||||
const econ=(await req('/api/player/state',{headers:auth(alice)})).body.player;assert.equal(econ.earnedScore,state.scoreAwarded);assert.equal(econ.availableScore,state.scoreAwarded);
|
||||
const forged=meta('B1',999999,123,p);r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:2,global:{nextId:2},metas:[forged],states:[{id:'B1',value:{paths:[],solved:false}}]})});assert.notEqual(r.response.status,200,'non-adjacent forged board must be rejected');
|
||||
const forgedDifficulty=meta('B1',1,123,p);forgedDifficulty.level=10;r=await req('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:2,global:{nextId:2},metas:[forgedDifficulty],states:[{id:'B1',value:{paths:[],solved:false}}]})});assert.equal(r.response.status,400,'client-authored difficulty must be rejected in favor of server-derived puzzle facts');
|
||||
console.log('Server authority security smoke test passed');
|
||||
})().catch(e=>{console.error(e);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{ws?.close();child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});
|
||||
|
|
|
|||
40
test/server-recovery-test.js
Normal file
40
test/server-recovery-test.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const fs=require('fs');
|
||||
const os=require('os');
|
||||
const path=require('path');
|
||||
|
||||
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-recovery-'));
|
||||
process.env.BEND_FIELD_DATA_DIR=dataDir;
|
||||
const server=require('../server');
|
||||
const worldFile=path.join(dataDir,'shared-world.json'),commitFile=path.join(dataDir,'shared-world.commit.json'),boardsDir=path.join(dataDir,'shared-world.boards');
|
||||
const playerId='aaaaaaaaaaaaaaaaaaaaaaaa',playerFile=path.join(dataDir,`${playerId}.json`);
|
||||
const player=earnedScore=>({playerId,name:'Player',tokenHash:'0'.repeat(64),purchases:[],generationBonuses:[],earnedScore,economyRevision:earnedScore,createdAt:1,updatedAt:1});
|
||||
const world=revision=>({revision,rowRevision:revision,boardVersions:{},changes:[],clearEvents:[],expansionGrants:{},global:{nextId:1},createdAt:1,updatedAt:1});
|
||||
|
||||
(async()=>{
|
||||
fs.mkdirSync(boardsDir,{recursive:true});
|
||||
fs.writeFileSync(worldFile,JSON.stringify(world(0)));
|
||||
fs.writeFileSync(playerFile,JSON.stringify(player(0)));
|
||||
fs.writeFileSync(path.join(boardsDir,'B0.1.json'),JSON.stringify({meta:{id:'B0'},state:{solved:true}}));
|
||||
const nextWorld=world(1);nextWorld.boardVersions.B0=1;
|
||||
fs.writeFileSync(commitFile,JSON.stringify({revision:1,world:nextWorld,nextPlayer:player(100),previousPlayer:player(0),changedBoardIds:['B0']}));
|
||||
assert.equal(await server.recoverPendingWorldCommit(),true);
|
||||
assert.equal(JSON.parse(fs.readFileSync(worldFile,'utf8')).revision,1);
|
||||
assert.equal(JSON.parse(fs.readFileSync(playerFile,'utf8')).earnedScore,100);
|
||||
assert.equal(fs.existsSync(commitFile),false);
|
||||
|
||||
fs.writeFileSync(path.join(boardsDir,'B0.0.json'),'{}');
|
||||
assert.equal(await server.collectRetiredBoardVersions(),1);
|
||||
assert.equal(fs.existsSync(path.join(boardsDir,'B0.1.json')),true);
|
||||
assert.equal(fs.existsSync(path.join(boardsDir,'B0.0.json')),false);
|
||||
|
||||
const failedWorld=world(2);failedWorld.boardVersions={B0:1,B1:2};
|
||||
fs.writeFileSync(playerFile,JSON.stringify(player(200)));
|
||||
fs.writeFileSync(commitFile,JSON.stringify({revision:2,world:failedWorld,nextPlayer:player(200),previousPlayer:player(100),changedBoardIds:['B1']}));
|
||||
assert.equal(await server.recoverPendingWorldCommit(),false);
|
||||
assert.equal(JSON.parse(fs.readFileSync(worldFile,'utf8')).revision,1);
|
||||
assert.equal(JSON.parse(fs.readFileSync(playerFile,'utf8')).earnedScore,100);
|
||||
assert.equal(fs.existsSync(commitFile),false);
|
||||
console.log('Shared-world commit recovery and retired-version collection passed');
|
||||
})().finally(()=>fs.rmSync(dataDir,{recursive:true,force:true})).catch(error=>{console.error(error);process.exitCode=1});
|
||||
|
|
@ -27,7 +27,8 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
|
|||
(async()=>{
|
||||
for(let i=0;i<60;i++){try{const {response}=await request('/api/cloud/status');if(response.ok)break}catch(_){}await sleep(50)}
|
||||
const status=await request('/api/cloud/status');assert.equal(status.response.status,200);assert.equal(status.body.sharedWorld,true);
|
||||
const page=await requestText('/');assert.equal(page.response.status,200);assert(page.body.indexOf('app-logic.js?v=47.77')>page.body.indexOf('puzzle-core.js?v=47.77-5')&&page.body.indexOf('app-logic.js?v=47.77')<page.body.indexOf('app.js?v=47.77'));
|
||||
const page=await requestText('/');assert.equal(page.response.status,200);assert(page.body.indexOf('build-meta.js')<page.body.indexOf('puzzle-core.js')&&page.body.indexOf('app-logic.js')>page.body.indexOf('puzzle-core.js')&&page.body.indexOf('app-logic.js')<page.body.indexOf('app.js'));
|
||||
const runtimeConfig=await requestText('/runtime-config.js');assert.equal(runtimeConfig.response.status,200);assert.match(runtimeConfig.body,/cloudApi:true/);
|
||||
const logicAsset=await requestText('/app-logic.js');assert.equal(logicAsset.response.status,200);assert.match(logicAsset.body,/BendAppLogic/);
|
||||
|
||||
const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body;
|
||||
|
|
|
|||
52
test/shared-contracts-test.js
Normal file
52
test/shared-contracts-test.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
'use strict';
|
||||
const assert=require('assert/strict');
|
||||
const vm=require('vm');
|
||||
const {read,functionSource,buildMeta}=require('./helpers/app-source');
|
||||
const SharedContracts=require('../shared-contracts');
|
||||
const ServerContracts=require('../server');
|
||||
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert.equal(buildMeta.PACKAGE_VERSION,packageVersion);
|
||||
assert.deepEqual(
|
||||
SharedContracts.normalizeSpecialMechanics(['internalGate','warp','crossing','lock','warp','invalid']),
|
||||
['crossing','internalGate','lock','warp']
|
||||
);
|
||||
assert.deepEqual(
|
||||
ServerContracts.sanitizeWorldGlobal({specialMechanicsSeen:['internalGate','warp','invalid']},{global:{}}).specialMechanicsSeen,
|
||||
['internalGate','warp']
|
||||
);
|
||||
|
||||
const catalog=JSON.parse(read('store-catalog.json'));
|
||||
const catalogMap=new Map(catalog.map(item=>[item.id,item]));
|
||||
const generatedExpected=`'use strict';\n// Generated from store-catalog.json by scripts/generate-store-catalog.js. Do not edit.\nglobalThis.BendStoreCatalog=Object.freeze(${JSON.stringify(catalog)}.map(item=>Object.freeze(item)));\n`;
|
||||
assert.equal(read('store-catalog.generated.js'),generatedExpected,'Browser store catalog is stale; run the catalog generator');
|
||||
const generatedContext={};vm.createContext(generatedContext);vm.runInContext(generatedExpected,generatedContext);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(generatedContext.BendStoreCatalog)),catalog);
|
||||
|
||||
const fixtures=[
|
||||
{purchaseId:'purchase:1',boardId:'B0',itemId:'score-lens',buyer:' Alice Example ',boughtAt:100,paidCost:200000},
|
||||
{purchaseId:'purchase:1',boardId:'B1',itemId:'score-lens',buyer:'duplicate-id',boughtAt:101,paidCost:200000},
|
||||
{purchaseId:'purchase:2',boardId:'B0',itemId:'score-lens',buyer:'duplicate-item',boughtAt:102,paidCost:200000},
|
||||
{purchaseId:'purchase:3',boardId:'B01',itemId:'score-lens',buyer:'bad-board',boughtAt:103,paidCost:200000},
|
||||
{purchaseId:'purchase:4',boardId:'B2',itemId:'cursor-face-1f600',buyer:'123456789012345678901234567890',boughtAt:104,paidCost:Number.MAX_SAFE_INTEGER},
|
||||
{purchaseId:'bad purchase',boardId:'B3',itemId:'score-lens',buyer:'bad-id',boughtAt:105,paidCost:200000}
|
||||
];
|
||||
const clientContext={
|
||||
SharedContracts,
|
||||
storeItem:id=>catalogMap.get(String(id||''))||null,
|
||||
MAX_SCORE:Number.MAX_SAFE_INTEGER,
|
||||
DEFAULT_PLAYER_NAME:'旅人'
|
||||
};
|
||||
vm.createContext(clientContext);
|
||||
vm.runInContext(`${functionSource('normalizePlayerPurchases')}\nthis.normalizePlayerPurchases=normalizePlayerPurchases;`,clientContext);
|
||||
const client=JSON.parse(JSON.stringify(clientContext.normalizePlayerPurchases(fixtures)));
|
||||
const server=ServerContracts.normalizePlayerPurchases(fixtures);
|
||||
assert.deepEqual(client,server,'Client and server purchase normalization diverged');
|
||||
assert.equal(client.length,2);
|
||||
assert.equal(client[0].buyer,'Alice Example');
|
||||
assert.equal(client[1].buyer.length,24);
|
||||
assert.equal(client[1].paidCost,Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const runtimeContext={globalThis:null};runtimeContext.globalThis=runtimeContext;vm.createContext(runtimeContext);vm.runInContext(read('runtime-config.js'),runtimeContext);
|
||||
assert.equal(runtimeContext.BendRuntimeConfig.cloudApi,false,'Static/file mode must not probe the cloud API');
|
||||
console.log('Canonical build, store, mechanics, runtime mode, and purchase contracts passed');
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
const assert=require('assert/strict');
|
||||
const vm=require('vm');
|
||||
const {functionSource,loadAppLogic}=require('./helpers/app-source');
|
||||
const {normalizeSpecialMechanics}=require('../shared-contracts');
|
||||
|
||||
const outboxContext={
|
||||
data:{states:{B0:{solved:false},B1:{solved:true}}},cloudApiEnabled:true,
|
||||
|
|
@ -33,18 +34,20 @@ assert.equal(leaseContext.sharedExpansionRepairDelay({solved:true,solvedById:'aa
|
|||
const localMeta={id:'B0',x:99,y:99,seed:1,chunks:[[0,0]],sealedSides:[],rev:9_999};
|
||||
const remoteMeta={id:'B0',x:0,y:0,seed:2,chunks:[[0,0]],sealedSides:[],rev:2_000};
|
||||
const authoritativeContext={
|
||||
cloudApiEnabled:true,
|
||||
data:{
|
||||
metas:{B0:localMeta},states:{B0:{solved:true,solvedBy:'Local',paths:[{cells:[[0,0]]}],rev:9_999}},
|
||||
nextId:99,solved:1,lastSolveAt:123,specialMechanicsSeen:['warp'],quarantine:{local:true},
|
||||
playerName:'Player',score:777,cursorStyle:'flag-jp'
|
||||
},
|
||||
isPlainObject:value=>value&&typeof value==='object'&&!Array.isArray(value),lastRevision:0,normalizedStateObjects:new Set(),
|
||||
cloudJournalMetaIds:new Set(['B0']),cloudJournalStateIds:new Set(['B0']),cloudJournalDeletedIds:new Set(),cloudOutboxDeleteKeys:new Set(),
|
||||
normalizeSpecialMechanics,
|
||||
cloudJournalMetaIds:new Set(['B0']),cloudJournalStateIds:new Set(['B0']),cloudJournalDeletedIds:new Set(),cloudOutboxDeleteKeys:new Set(),cloudJournalChangeSeq:0,
|
||||
sameMetaGeometry:(a,b)=>a.x===b.x&&a.y===b.y&&a.seed===b.seed&&JSON.stringify(a.chunks)===JSON.stringify(b.chunks)&&JSON.stringify(a.sealedSides||[])===JSON.stringify(b.sealedSides||[]),
|
||||
destroyBoard:()=>{},destroyStaticBoard:()=>{},rendered:new Map(),staticRendered:new Map(),deepClone:value=>JSON.parse(JSON.stringify(value)),
|
||||
destroyBoard:()=>{},rendered:new Map(),deepClone:value=>JSON.parse(JSON.stringify(value)),sameDataValue:(a,b)=>JSON.stringify(a)===JSON.stringify(b),
|
||||
compareRevisionVersions:(a,b)=>(a?.rev||0)-(b?.rev||0),
|
||||
mergeBoardStates:(current,incoming)=>{
|
||||
if(current?.solved&&!incoming?.solved)return JSON.parse(JSON.stringify(incoming));
|
||||
if(current?.solved&&!incoming?.solved)return JSON.parse(JSON.stringify(current));
|
||||
if(current&&!current.solved&&!incoming.solved)return{...JSON.parse(JSON.stringify(incoming)),paths:JSON.parse(JSON.stringify(current.paths||[]))};
|
||||
return JSON.parse(JSON.stringify(incoming));
|
||||
},
|
||||
|
|
@ -52,17 +55,26 @@ const authoritativeContext={
|
|||
mergeGlobalFields:()=>{throw new Error('Authoritative shared global unexpectedly used generic merge')},resolveMergedOverlaps:()=>[],statsDirty:false
|
||||
};
|
||||
vm.createContext(authoritativeContext);
|
||||
vm.runInContext(`${functionSource('clearSharedWorldJournalRow')}\n${functionSource('applyAuthoritativeSharedGlobal')}\n${functionSource('mergeSnapshotIntoData')}\nthis.mergeSnapshotIntoData=mergeSnapshotIntoData;`,authoritativeContext);
|
||||
vm.runInContext(`${functionSource('clearSharedWorldJournalRow')}\n${functionSource('noteCloudRow')}\n${functionSource('applyAuthoritativeSharedGlobal')}\n${functionSource('mergeSnapshotIntoData')}\nthis.mergeSnapshotIntoData=mergeSnapshotIntoData;`,authoritativeContext);
|
||||
authoritativeContext.mergeSnapshotIntoData({
|
||||
metas:{B0:remoteMeta},states:{B0:{solved:false,paths:[],rev:2_000}},
|
||||
nextId:2,solved:0,lastSolveAt:0,specialMechanicsSeen:['lock'],quarantine:{shared:true}
|
||||
},{finalize:false,authoritativeWorld:true});
|
||||
assert.equal(authoritativeContext.data.metas.B0.seed,2,'An existing local world row overrode the authoritative shared board');
|
||||
assert.equal(authoritativeContext.data.states.B0.solved,false,'An unconfirmed local clear survived authoritative shared adoption');
|
||||
assert.equal(authoritativeContext.data.states.B0.solved,false,'A clear from a replaced board definition leaked into the authoritative shared board');
|
||||
assert.equal(authoritativeContext.data.nextId,2,'A private local board counter leaked into the shared world');
|
||||
assert.equal(authoritativeContext.data.playerName,'Player');assert.equal(authoritativeContext.data.score,777);assert.equal(authoritativeContext.data.cursorStyle,'flag-jp');
|
||||
assert.equal(authoritativeContext.cloudJournalMetaIds.size,0);assert.equal(authoritativeContext.cloudJournalStateIds.size,0);
|
||||
assert(authoritativeContext.cloudOutboxDeleteKeys.has('meta:B0')&&authoritativeContext.cloudOutboxDeleteKeys.has('state:B0'),'Adopted shared rows did not clear stale local outbox records');
|
||||
assert(authoritativeContext.cloudOutboxDeleteKeys.has('meta:B0')&&authoritativeContext.cloudOutboxDeleteKeys.has('state:B0'),'Replaced shared rows did not clear stale local outbox records');
|
||||
|
||||
// A durable clear for the same board definition survives a stale authoritative snapshot and is queued for upload.
|
||||
authoritativeContext.cloudJournalStateIds.clear();authoritativeContext.cloudOutboxDeleteKeys.clear();
|
||||
authoritativeContext.data.metas.B0=JSON.parse(JSON.stringify(remoteMeta));
|
||||
authoritativeContext.data.states.B0={solved:true,solvedBy:'Local',paths:[{cells:[[0,0]]}],rev:9_999};
|
||||
authoritativeContext.mergeSnapshotIntoData({metas:{B0:{...remoteMeta,rev:4_000}},states:{B0:{solved:false,paths:[],rev:4_000}},nextId:2},{finalize:false,authoritativeWorld:true});
|
||||
assert.equal(authoritativeContext.data.states.B0.solved,true,'A durable clear was downgraded by a stale authoritative snapshot of the same board');
|
||||
assert.deepEqual([...authoritativeContext.cloudJournalStateIds],['B0'],'The retained clear was not queued for shared upload');
|
||||
assert.equal(authoritativeContext.cloudOutboxDeleteKeys.has('state:B0'),false,'The retained clear was incorrectly scheduled for deletion');
|
||||
|
||||
// Matching unsolved boards keep the player's unfinished line locally while the board definition stays shared.
|
||||
authoritativeContext.data.metas.B0=remoteMeta;
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ const app=read('app.js'),html=read('index.html'),css=read('style.css'),serverSou
|
|||
assert(html.includes('id="reactionCanvas"')&&html.includes('id="reactionRadial"')&&css.includes('#reactionCanvas')&&css.includes('z-index:2'),'Reaction layer is not behind boards');
|
||||
assert(functionSource('beginReactionGesture').includes('REACTION_LONG_PRESS_MS')&&functionSource('reactionAllowedAt').includes('metaState(boardId).solved'),'Reaction click/long-press eligibility is missing');
|
||||
assert(functionSource('drawReactionLayer').includes('REALTIME_REACTION_DURATION')||app.includes('REALTIME_REACTION_DURATION=4500'),'Reaction animation is not bounded');
|
||||
assert(functionSource('inventoryEntries').includes('onlinePlayerEconomy()')&&functionSource('purchaseStoreItem').includes('buyPersonalStoreItem'),'Player inventory or personal purchasing is not server-backed');
|
||||
assert(serverSource.includes("url.pathname==='/api/player/purchase'")&&serverSource.includes('assertPlayerCanAfford')&&!serverSource.includes("url.pathname==='/api/player/place-field'"),'Player purchase validation is missing or retired field-placement API remains');
|
||||
assert(functionSource('inventoryEntries').includes('personalEconomyMode()')&&functionSource('purchaseStoreItem').includes('buyPersonalStoreItem'),'Player inventory or personal purchasing is not server-backed');
|
||||
assert(serverSource.includes(".add('POST','/api/player/purchase',handlePurchase)")&&serverSource.includes('assertPlayerCanAfford')&&!serverSource.includes("'/api/player/place-field'"),'Player purchase validation is missing or retired field-placement API remains');
|
||||
assert(realtimeSource.includes("message.type === 'reaction'")&&realtimeSource.includes('REACTION_MIN_INTERVAL_MS')&&!realtimeSource.includes('broadcastFieldEffect'),'Realtime reaction throttling is missing or field broadcasts remain');
|
||||
|
||||
const port=24000+Math.floor(Math.random()*8000),dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-complete-'));
|
||||
|
|
@ -29,14 +29,20 @@ function solvedState(puzzle){return{paths:puzzle.solution.map(route=>({startGate
|
|||
const status=(await request('/api/cloud/status')).body;assert.equal(status.reactions,true);assert.equal(status.playerEconomy,true);assert.equal(status.sharedItems,false);
|
||||
const alice=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Alice'})})).body;
|
||||
const bob=(await request('/api/cloud/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:'Bob'})})).body;
|
||||
const puzzle=starterPuzzle(),b0=boardMeta('B0',0,15,puzzle);
|
||||
const puzzle=starterPuzzle(),route=[[0,1],[0,0],[1,0],[2,0],[3,0],[4,0],[4,1],[3,1],[2,1],[1,1],[1,2],[0,2],[0,3],[0,4],[1,4],[1,3],[2,3],[2,4],[3,4],[4,4],[4,3],[3,3],[3,2],[4,2]];
|
||||
puzzle.g=[[0,1,'N'],[4,2,'S']];puzzle.n=[[0,0,10]];puzzle.valid=route.map(cell=>[...cell]);puzzle.obstacles=[[2,2]];puzzle.solution=[{startGate:0,endGate:1,cells:route.map(cell=>[...cell])}];puzzle.specialCells={crossings:[],warps:[],locks:[],internalGates:[]};puzzle.maxTurns=10;puzzle.totalTurns=10;
|
||||
const b0=boardMeta('B0',0,15,puzzle);
|
||||
let pushed=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{schema:31,appVersion:'47.70',generatorVersion:5,worldGeneration:'v47-field-reset-20260728-interaction-fix',nextId:1},metas:[b0],states:[{id:'B0',value:{paths:[],solved:false,rev:1,revAuthor:'alice'}}],deleted:[]})});assert.equal(pushed.response.status,200);
|
||||
aliceWs=await connectRealtime(base,alice);bobWs=await connectRealtime(base,bob);aliceWs.send({type:'viewport',minX:-8,minY:-8,maxX:8,maxY:8});bobWs.send({type:'viewport',minX:-8,minY:-8,maxX:8,maxY:8});await aliceWs.waitFor('snapshot');await bobWs.waitFor('snapshot');
|
||||
aliceWs.send({type:'reaction',id:'reaction-one',emoji:'🎉',x:.4,y:.6});const reaction=await bobWs.waitFor(message=>message.type==='reaction'&&message.reaction?.id==='reaction-one');assert.equal(reaction.reaction.emoji,'🎉');assert.equal(reaction.reaction.playerName,'Alice');assert(reaction.reaction.expiresAt>reaction.reaction.createdAt);
|
||||
aliceWs.send({type:'claim',requestId:'claim-b0',boardId:'B0'});assert.equal((await aliceWs.waitFor(message=>message.type==='claim-result'&&message.requestId==='claim-b0')).ok,true);
|
||||
pushed=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:solvedState(puzzle)}],deleted:[]})});assert.equal(pushed.response.status,200);let revision=pushed.body.revision;
|
||||
const alicePath=path.join(dataDir,`${alice.playerId}.json`),aliceRecord=JSON.parse(fs.readFileSync(alicePath,'utf8'));aliceRecord.earnedScore=250000;fs.writeFileSync(alicePath,JSON.stringify(aliceRecord));
|
||||
const purchase=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'score-lens'})});assert.equal(purchase.response.status,201);assert.equal(purchase.body.player.purchases.length,1);assert.equal(purchase.body.purchase.itemId,'score-lens');
|
||||
const [renamed,purchase]=await Promise.all([
|
||||
request('/api/cloud/profile',{method:'POST',headers:auth(alice),body:JSON.stringify({name:'Alice Concurrent'})}),
|
||||
request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'score-lens'})})
|
||||
]);assert.equal(renamed.response.status,200);assert.equal(purchase.response.status,201);assert.equal(purchase.body.player.purchases.length,1);assert.equal(purchase.body.purchase.itemId,'score-lens');
|
||||
const racedRecord=JSON.parse(fs.readFileSync(alicePath,'utf8'));assert.equal(racedRecord.name,'Alice Concurrent');assert.equal(racedRecord.purchases.length,1,'Concurrent profile update overwrote the purchase');
|
||||
const duplicate=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'score-lens'})});assert.equal(duplicate.response.status,200);assert.equal(duplicate.body.purchase.purchaseId,purchase.body.purchase.purchaseId);
|
||||
const bobState=await request('/api/player/state',{headers:auth(bob)});assert.deepEqual(bobState.body.player.purchases,[]);
|
||||
const retiredPurchase=await request('/api/player/purchase',{method:'POST',headers:auth(alice),body:JSON.stringify({boardId:'B0',itemId:'level-min-10'})});assert.equal(retiredPurchase.response.status,400);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
'use strict';
|
||||
const cp=require('child_process');
|
||||
const fs=require('fs');
|
||||
const {root,path,vm,app,html,css,worker,appLogicSource,assert,functionSource,loadBendPuzzle,loadAppLogic,starterPuzzle,read}=require('./helpers/app-source');
|
||||
const {root,path,vm,app,html,css,worker,appLogicSource,buildMeta,assert,functionSource,loadBendPuzzle,loadAppLogic,starterPuzzle,read}=require('./helpers/app-source');
|
||||
const serverSource=read('server.js');
|
||||
const packageVersion=JSON.parse(read('package.json')).version,appVersion=packageVersion.split('.').slice(0,2).join('.');
|
||||
const storeCatalog=JSON.parse(read('store-catalog.json'));
|
||||
for(const file of ['app.js','app-logic.js','puzzle-core.js','puzzle-worker.js','field-persistence.js','field-persistence-worker.js','server.js'])cp.execFileSync(process.execPath,['--check',path.join(root,file)],{stdio:'inherit'});
|
||||
assert(buildMeta.APP_VERSION===appVersion&&buildMeta.SAVE_SCHEMA===31&&buildMeta.STORAGE_SCHEMA===30&&buildMeta.IDB_LAYOUT_VERSION===8&&buildMeta.FIELD_STORAGE_FORMAT===2&&buildMeta.GAMEPLAY_DATA_VERSION===3&&buildMeta.WORLD_GENERATION==='v47-field-reset-20260728-interaction-fix','Canonical build metadata does not match the package or persistence contracts');
|
||||
for(const marker of [
|
||||
`APP_VERSION='${appVersion}',SAVE_SCHEMA=31,STORAGE_SCHEMA=30,IDB_LAYOUT_VERSION=8,FIELD_STORAGE_FORMAT=2,GAMEPLAY_DATA_VERSION=3,WORLD_GENERATION='v47-field-reset-20260728-interaction-fix'`,
|
||||
'SPECIAL_CELL_MIN_LEVEL=5,SPECIAL_CELL_DEBUG_ALL_LEVELS=false',
|
||||
'SPECIAL_CELL_MIN_LEVEL=5',
|
||||
'shapeCandidatesForLevel','addSpecialCellPattern','addWarpSpecial','addLockSpecial','addCrossingSpecial',
|
||||
'specialPathValid','crossingsSatisfied','activateCrossing','pathRenderSegments','pathStrokePieces','pathProgressAtCell','specialCellInfoMap','normalizeSpecialCells'
|
||||
])assert(app.includes(marker),`Missing v47 marker: ${marker}`);
|
||||
assert(!app.includes('SPECIAL_CELL_DEBUG_ALL_LEVELS'),'Special-cell generation still contains a debug-all-levels switch');
|
||||
assert(app.includes('const AppLogic=globalThis.BendAppLogic')&&app.includes('AppLogic.shapeCandidatesForLevel')&&app.includes('AppLogic.stateForStorage')&&app.includes('AppLogic.collectConnectedLineComponent'),'Application does not consume the shared pure-logic module');
|
||||
assert(html.includes(`style.css?v=${appVersion}`)&&html.includes(`puzzle-core.js?v=${appVersion}-5`)&&html.includes(`app-logic.js?v=${appVersion}`)&&html.includes(`field-persistence.js?v=${appVersion}`)&&html.includes(`app.js?v=${appVersion}`),'Web assets do not match package/app/generator version');
|
||||
assert(worker.includes(`puzzle-core.js?v=${appVersion}-5`),'Worker imports an old puzzle-core asset');
|
||||
assert(html.includes('build-meta.js')&&html.indexOf('build-meta.js')<html.indexOf('puzzle-core.js')&&html.indexOf('shared-contracts.js')<html.indexOf('app.js'),'Canonical metadata/contracts are not loaded before application assets');
|
||||
assert(worker.includes('build-meta.js')&&worker.includes('BendBuildMeta.APP_VERSION')&&worker.includes('BendBuildMeta.GENERATOR_VERSION'),'Worker does not derive its puzzle-core asset version from canonical metadata');
|
||||
assert(functionSource('createPuzzleWorker').includes('message.error?job.reject')&&!functionSource('createPuzzleWorker').includes('message.error?generatePuzzleOnMainThread'),'Algorithmic worker failures are retried redundantly on the main thread');
|
||||
assert(html.includes(`<small>v${appVersion}</small>`)&&html.includes(`v${appVersion}</title>`),'Visible version does not match package/app version');
|
||||
assert(app.includes('const appVersionLabel=`v${APP_VERSION}`')&&app.includes('brandVersion.textContent=appVersionLabel')&&app.includes('document.title=`${document.title.replace('),'Visible version is not derived from canonical build metadata');
|
||||
assert(html.includes('id="viewport" tabindex="-1"')&&['modal','storeModal','inventoryModal','timeAttackModal'].every(id=>html.includes(`id="${id}" aria-hidden="true" inert`)),'Hidden dialogs are not inert or the viewport is not programmatically focusable');
|
||||
assert(functionSource('closeDialogRoot').includes('focusOutsideDialog(root,preferredFocus)')&&functionSource('closeDialogRoot').indexOf('focusOutsideDialog')<functionSource('closeDialogRoot').indexOf("setAttribute('aria-hidden','true')"),'Dialog hiding occurs before focus leaves the dialog');
|
||||
assert(functionSource('openDialogRoot').includes('setDialogInert(root,false)')&&functionSource('closeDialogRoot').includes('setDialogInert(root,true)'),'Dialog inert state is not synchronized with visibility');
|
||||
|
|
@ -23,7 +25,7 @@ assert(!app.includes('labyrinth-seed')&&!app.includes('giantCompactShapes')&&!ap
|
|||
assert(!app.includes('anomalyAt')&&!app.includes('anomalyScoreMultiplier')&&!app.includes('renderAnomalyOverlays')&&!css.includes('.anomaly'),'Retired anomaly code remains');
|
||||
assert(!serverSource.includes('migrateLegacyPlayer')&&!serverSource.includes('value.metas')&&!serverSource.includes('value.states'),'Cloud server still reads or migrates the retired monolithic player format');
|
||||
assert(html.includes('id="clearFeed"')&&css.includes('.clear-feed-item'),'Shared clear feed is missing above the minimap');
|
||||
assert(serverSource.includes("const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json')")&&serverSource.includes("url.pathname==='/api/cloud/profile'")&&serverSource.includes('withWorldQueue')&&serverSource.includes('clearEvents'),'Shared-world storage, profile naming, serialization, or clear feed API is missing');
|
||||
assert(serverSource.includes("const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json')")&&serverSource.includes(".add('POST','/api/cloud/profile',handleCloudProfile)")&&serverSource.includes('withWorldQueue')&&serverSource.includes('clearEvents'),'Shared-world storage, profile naming, serialization, or clear feed API is missing');
|
||||
assert(functionSource('checkSolvedAndExpand').indexOf('pullCloudWorld(true)')<functionSource('checkSolvedAndExpand').indexOf('st.solved=true')&&functionSource('checkSolvedAndExpand').indexOf('pushCloudPending()')<functionSource('checkSolvedAndExpand').indexOf('expandMeta('),'Clear publication is not server-validated before shared expansion');
|
||||
assert(functionSource('mergeSnapshotIntoData').includes('authoritativeWorld')&&functionSource('pullCloudWorld').includes('initial&&previousRevision===0&&targetRevision>0')&&functionSource('clearSharedWorldJournalRow').includes('cloudOutboxDeleteKeys'),'Initial shared-world adoption does not replace stale local world rows or clean their outbox entries');
|
||||
assert(functionSource('noteCloudRow').includes("kind==='state'&&data?.states?.[id]?.solved!==true")&&functionSource('currentCloudPending').includes("solved===true"),'Unfinished personal paths can still enter the shared durable outbox');
|
||||
|
|
@ -42,7 +44,7 @@ assert(functionSource('makeSpecialMarker').includes("class:'key-ring'")&&!functi
|
|||
assert(css.includes('.board-card.solved .special-cell-layer'),'Solved boards do not hide special cells');
|
||||
assert(functionSource('selectBoard').includes('setActiveBoard(b.id)')&&functionSource('setActiveBoard').includes('previous.drawing?.pointerId==null'),'Board selection does not preserve an active pointer draw while reconciling inactive keyboard state');
|
||||
assert(!css.includes('.board-card:not(.active)'),'Unselected-board styling remains active');
|
||||
assert(css.includes('.board-card.hud-current:not(.solved) .board-label')&&functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('renderBoardNow').includes("card.classList.toggle('hud-current',hudVisible)"),'Board HUD is not limited to an actively played board');
|
||||
assert(html.includes('id="boardHudLayer"')&&css.includes('.board-label.hud-visible:not([hidden])')&&css.includes('.board-label[hidden]{display:none!important}')&&functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)')&&functionSource('refreshInteractionState').includes('setBoardHudVisibility(board,boardPlayHudVisible(board))')&&functionSource('renderBoardNow').includes('setBoardHudVisibility(b,hudVisible)'),'Board HUD is not retained for the last manipulated board or is still clipped inside the board');
|
||||
assert(functionSource('gateFromCell').includes('maxPixels')&&functionSource('extendPointerTo').includes('active.startGate,20'),'Opposite gate selection is not distance-limited');
|
||||
assert(functionSource('renderBoardNow').includes('pathStrokePieces(segments,startColor,endColor)')&&functionSource('pathColorAtCell').includes('pathProgressAtCell'),'Line colors are not blended along cumulative route length');
|
||||
assert(functionSource('updateSelectedProgress').includes('b.meta.level')&&!functionSource('updateSelectedProgress').includes('filled'),'Top HUD includes information other than level');
|
||||
|
|
@ -53,22 +55,22 @@ assert(functionSource('syncBoundaryConnections').includes('boundaryColorSource(m
|
|||
assert(!app.includes('level-min-10')&&!app.includes('level-max-10')&&!app.includes('fieldEffects')&&!app.includes('fieldOverlayCanvas')&&!serverSource.includes('/api/player/place-field'),'Difficulty adjustment items or their field implementation remain active');
|
||||
assert(functionSource('makeBoard').includes('cellShape')&&functionSource('makeBoard').includes('boardCellsPath')&&!functionSource('makeBoard').includes('cellHits'),'Detailed boards do not use compound SVG paths or still allocate per-cell hit nodes');
|
||||
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET'),'Visible puzzles are not all selected for detailed rendering');
|
||||
assert(functionSource('shouldTeleportToUnsolvedBoard').includes('return false')&&!app.includes('function promoteStaticBoard(')&&!functionSource('bindBoard').includes('centerMeta('),'Board input can still promote a summary or teleport the camera');
|
||||
assert(!app.includes('shouldTeleportToUnsolvedBoard')&&!app.includes('makeStaticBoard')&&!app.includes('promoteStaticBoard')&&!functionSource('bindBoard').includes('centerMeta('),'Board input can still promote a summary or teleport the camera');
|
||||
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60'),'Lightweight FPS display or split interaction budgets are missing');
|
||||
assert(css.includes('.gem-particle{position:fixed')&&css.includes('width:28px;height:28px')&&functionSource('completionEffect').includes('1800'),'Completion gems are not enlarged or retained long enough');
|
||||
assert(css.includes('#customEmojiCursor{')&&css.includes('overflow:visible')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden'),'Non-flag custom cursors are still clipped or flag clipping is no longer isolated');
|
||||
assert(css.includes('#customEmojiCursor{')&&css.includes('overflow:visible')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden')&&css.includes('.board-input-surface')&&css.includes('body.is-drawing #viewport')&&functionSource('updateCustomCursorFromPointer').includes("classList.contains('is-drawing')"),'Custom cursor coverage or drag-time cursor hiding is incomplete');
|
||||
|
||||
assert(functionSource('extendOne').includes('warpedDuringExtend=true')&&!functionSource('extendOne').includes('safeRelease(b.svg,pointerId)'),'Warp traversal still releases pointer capture');
|
||||
assert(functionSource('extendPointerTo').includes('pointerOffset'),'Warp continuation does not remap the pointer to the exit');
|
||||
assert(!app.includes('hysteresisDragPoint')&&!app.includes('DRAG_AXIS_LOCK_DISTANCE')&&!app.includes('POINTER_SNAP_RELEASE'),'Retired drawing hysteresis remains active');
|
||||
assert(functionSource('scheduleBoardDragFrame').includes('requestAnimationFrame')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&functionSource('bindBoard').includes('queueBoardPointerMove')&&functionSource('processBoardDragFrame').includes('const move=b.pendingPointerMove'),'Pointer movement is not frame-batched with ordered samples');
|
||||
assert(functionSource('queueCameraInteraction').includes('requestAnimationFrame')&&functionSource('movePan').includes('queueCameraInteraction'),'Camera panning is not frame-coalesced');
|
||||
assert(functionSource('scheduleBoardDragFrame').includes('requestFrame')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&!functionSource('queueBoardPointerMove').includes('updatePickupHandleOverlay')&&functionSource('bindBoard').includes('queueBoardPointerMove')&&functionSource('processBoardDragFrame').includes('scheduler.latest()')&&functionSource('processBoardDragFrame').includes('updatePickupHandleOverlay')&&!functionSource('processBoardDragFrame').includes('visualBlend')&&!css.includes('transition:transform 16.67ms linear'),'Pointer logic or pickup presentation is not confined to the capped frame lane');
|
||||
assert(functionSource('queueCameraInteraction').includes('cameraInteractionScheduler.push')&&functionSource('movePan').includes('queueCameraInteraction'),'Camera panning is not frame-coalesced');
|
||||
assert(functionSource('leftFieldPanAllowed').includes("classList?.contains('solved')")&&functionSource('beginPan').includes('e.button!==2&&!leftFieldPanAllowed(e)'),'Left-drag field panning is not isolated to solved or undiscovered space');
|
||||
assert(functionSource('processBoardDragFrame').includes('edgePanVelocity')&&functionSource('processBoardDragFrame').includes('applyCamera(true)'),'Drag edge auto-pan is missing');
|
||||
assert(!functionSource('makeBoard').includes('darkness')&&!css.includes('.darkness'),'Retired darkness rendering remains');
|
||||
assert(functionSource('positionBoardLabel').includes('hudPlacementCandidates')&&functionSource('positionBoardLabel').includes("placement.side==='S'"),'HUD does not move to a free edge');
|
||||
assert(functionSource('positionBoardLabel').includes("b.label.style.width=innerWidth+'px'")&&functionSource('positionBoardLabel').includes("b.label.style.top=(PAD-22)+'px'"),'Top/bottom HUD does not span the board edge or clear upper gates');
|
||||
assert(functionSource('gateHitBox').includes("side==='N'")&&functionSource('makeBoard').includes('...gateHitBox(gp,g.side)'),'Gate hit areas are not constrained to the owning board');
|
||||
assert(functionSource('positionBoardLabel').includes('hudPlacementCandidates')&&functionSource('positionBoardLabel').includes("side==='S'"),'HUD does not move to a free edge');
|
||||
assert(functionSource('positionBoardLabel').includes('viewportRect.width-margin-labelWidth')&&functionSource('positionBoardLabel').includes('viewportRect.height-margin-labelHeight')&&functionSource('makeBoard').includes('boardHudLayer?.append(label)'),'Board HUD is not viewport-clamped in a detached overlay layer');
|
||||
assert(functionSource('gateHitBox').includes("side==='N'")&&functionSource('makeBoard').includes('...gateHitBox(gp,g.side,g.internal)'),'Gate hit areas are not constrained to the owning board');
|
||||
assert(!app.includes('sharedGateVisible'),'No-op shared-gate visibility wrapper remains');
|
||||
assert(functionSource('isSolved').includes('crossingsSatisfied(st,p)'),'Crossing is not a prerequisite for normal board completion');
|
||||
assert(functionSource('crossingsSatisfied').includes('crossingStateAtCell')&&functionSource('activateCrossing').includes('path.cells.push(cell)'),'Crossing is not derived from the live overlapping line state');
|
||||
|
|
@ -78,9 +80,9 @@ assert(functionSource('resetSelectedBoard').includes('specialProgress={crossings
|
|||
assert(functionSource('resetSelectedBoard').includes('renderBoardNow(b)')&&!functionSource('resetSelectedBoard').includes('syncBoundaryConnections'),'Reset is not immediate or still recreates inherited routes');
|
||||
assert(!app.toLowerCase().includes('undolastreset')&&!html.toLowerCase().includes('undo'),'Visible reset undo remains active');
|
||||
|
||||
assert(app.includes('STORE_CHANCE=1/30'),'Store appearance rate is not 1/30');
|
||||
assert(app.includes('STORE_CHANCE=1/10'),'Store appearance rate is not 1/10');
|
||||
assert(app.includes('MINIMAP_VIEW_CHUNKS_X=42'),'Minimap does not use the wider scale');
|
||||
assert(app.includes('SOUND_GAIN_MULTIPLIER=3.6')&&functionSource('soundTone').includes('Math.min(.28'),'Sound effects were not amplified');
|
||||
assert(app.includes('SOUND_GAIN_MULTIPLIER=5.2')&&functionSource('soundTone').includes('Math.min(.42'),'Sound effects were not amplified');
|
||||
assert(app.includes('UNIQUE_SOLUTION_MIN_LEVEL=6')&&functionSource('placeChildAtFrontierAttempt').includes('level>=UNIQUE_SOLUTION_MIN_LEVEL'),'Unique-solution selection does not begin at level 6');
|
||||
assert(functionSource('shapeCandidatesForArea').includes('nearbyShapeFamilyCounts')&&appLogicSource.includes('generatedShapeFamilyKey')&&appLogicSource.includes('balancedShapeCandidates'),'Area-local board shape balancing is missing');
|
||||
assert(!html.includes('矢印キー:線を伸ばす')&&css.includes('.control-chips span{padding:9px 12px')&&css.includes('font-size:13px'),'Help controls are too small or still list arrow keys');
|
||||
|
|
@ -89,31 +91,32 @@ assert(functionSource('addCrossingSpecial').includes('neighbors.some(candidate=>
|
|||
assert(functionSource('updateZoomPresentation').includes('world-overview')&&functionSource('drawWorldOverview').includes('overviewCanvas')&&html.includes('id="overviewCanvas"')&&css.includes('#viewport.canvas-overview #world'),'Canvas overview mode is missing');
|
||||
assert(!functionSource('drawWorldOverview').includes('drawWorldOverviewPaths')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines'),'Far zoom does not use the minimap long-line renderer');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!app.includes('function drawWorldOverviewShops('),'Zoomed-out rendering does not share the minimap renderer');
|
||||
assert(functionSource('makeStaticBoard').includes('renderedConnectedLineWidth(meta,index)')&&!functionSource('makeStaticBoard').includes('state.solved')&&css.includes('.static-summary-path{'),'Unsolved nearby boards do not preserve route thickness');
|
||||
assert(!app.includes('makeStaticBoard')&&!css.includes('.static-summary'),'Inactive static-board LOD code remains');
|
||||
assert(functionSource('updateTimeAttackUi').includes("classList.toggle('starting'")&&css.includes('@keyframes timeAttackStartEmphasis'),'Time-attack start clock emphasis is missing');
|
||||
const yellowFaces=app.match(/const YELLOW_FACE_CURSOR_SOURCE=`([\s\S]*?)`;/)?.[1]?.split('\n')||[];
|
||||
assert(yellowFaces.length===101&&yellowFaces.some(row=>row.startsWith('1FAE9|'))&&yellowFaces.some(row=>row.startsWith('1FAEA|')),'Complete Unicode Emoji 17.0 yellow-face cursor catalog is missing');
|
||||
assert(app.includes('MAX_FACE_CURSOR_PRICE=50000')&&app.includes('cost=MIN_CURSOR_PRICE+((index*61+37)%100)*MIN_CURSOR_PRICE')&&functionSource('storeItemPrice').includes('Math.min(MAX_FACE_CURSOR_PRICE,adjusted)'),'Yellow-face cursor prices are not deterministic random values spanning 500-50000 gems');
|
||||
const faceContracts=storeCatalog.filter(item=>item.id.startsWith('cursor-face-'));
|
||||
assert(faceContracts.length===101&&Math.min(...faceContracts.map(item=>item.cost))===500&&Math.max(...faceContracts.map(item=>item.cost))===50000&&app.includes('MAX_FACE_CURSOR_PRICE=50000')&&functionSource('storeItemPrice').includes('Math.min(MAX_FACE_CURSOR_PRICE,adjusted)'),'Canonical yellow-face cursor prices do not span 500-50000 gems');
|
||||
const flagCodes=app.match(/const FLAG_REGION_CODES=`([^`]+)`\.split\(' '\)/)?.[1]?.split(' ')||[];
|
||||
assert(flagCodes.length===259&&new Set(flagCodes).size===259&&app.includes("['gbeng','England'],['gbsct','Scotland'],['gbwls','Wales']"),'Complete Unicode Emoji 17.0 flag cursor catalog is missing');
|
||||
const oecdCodes=app.match(/const OECD_FLAG_CODES=new Set\('([^']+)'\.split\(' '\)\)/)?.[1]?.split(' ')||[];
|
||||
assert(oecdCodes.length===38&&new Set(oecdCodes).size===38&&app.includes('OECD_FLAG_CURSOR_BASE_PRICE=20000')&&app.includes('FLAG_CURSOR_BASE_PRICE=10000'),'Flag cursor base prices or the 38-country OECD tier are missing');
|
||||
const oecdCodes='AU AT BE CA CL CO CR CZ DK EE FI FR DE GR HU IS IE IL IT JP KR LV LT LU MX NL NZ NO PL PT SK SI ES SE CH TR GB US'.split(' ');
|
||||
assert(oecdCodes.length===38&&oecdCodes.every(code=>storeCatalog.find(item=>item.id===`cursor-flag-${code.toLowerCase()}`)?.cost===20000)&&storeCatalog.filter(item=>item.id.startsWith('cursor-flag-')).every(item=>item.cost===10000||item.cost===20000),'Canonical flag prices or the 38-country OECD tier are missing');
|
||||
const flagAssetDir=path.join(root,'assets','flags'),flagAssets=fs.readdirSync(flagAssetDir).filter(name=>name.endsWith('.svg'));
|
||||
assert(flagAssets.length===262&&fs.existsSync(path.join(flagAssetDir,'LICENSE-TWEMOJI.txt')),'Bundled cross-platform flag SVG catalog or attribution is incomplete');
|
||||
assert(functionSource('syncCursorAppearance').includes("image.src=selected.flagAsset")&&functionSource('setItemIcon').includes("image.src=item.flagAsset")&&functionSource('syncCursorAppearance').includes('nativeSupported')&&css.includes('cursor:none!important')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden')&&css.includes('border-radius:50%'),'Native/DOM SVG-backed circular flag cursor rendering is missing');
|
||||
assert(functionSource('buildDragCache').includes('endpoint-cursor-image')&&functionSource('buildDragCache').includes('DRAG_FLAG_CLIP_RADIUS')&&functionSource('buildDragCache').includes('x:-DRAG_FLAG_CURSOR_SIZE/2')&&functionSource('buildDragCache').includes("class:'drag-tip-group'")&&functionSource('updateDragCursorDesign').includes('activeCustomCursorItem')&&functionSource('renderDragFrame').includes('cache.tipGroup.style.transform')&&functionSource('bindBoard').includes('queueMicrotask(()=>updateCustomCursorFromPointer(e))')&&!css.includes('body.is-drawing #customEmojiCursor{display:none!important}'),'Grabbed cursor sizing, centering, clipping, or smooth cursor continuity is missing');
|
||||
assert(functionSource('syncCursorAppearance').includes("image.src=selected.flagAsset")&&functionSource('setItemIcon').includes("image.src=item.flagAsset")&&functionSource('syncCursorAppearance').includes('dataset.cursorMode=presentation.mode')&&css.includes('cursor:none!important')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden')&&css.includes('border-radius:50%'),'DOM SVG-backed circular flag cursor rendering is missing');
|
||||
assert(functionSource('buildDragCache').includes('endpoint-cursor-image')&&functionSource('buildDragCache').includes('DRAG_FLAG_CLIP_RADIUS')&&functionSource('buildDragCache').includes('x:-DRAG_FLAG_CURSOR_SIZE/2')&&functionSource('buildDragCache').includes("class:'drag-tip-group'")&&functionSource('updateDragCursorDesign').includes('activeCustomCursorItem')&&functionSource('renderDragFrame').includes('cache.tipGroup.style.transform')&&functionSource('bindBoard').includes('queueMicrotask(()=>updateCustomCursorFromPointer(e))')&&css.includes('body.is-drawing #customEmojiCursor{display:none!important}'),'Grabbed cursor sizing, centering, clipping, or drag-time cursor hiding is missing');
|
||||
assert(css.includes('@font-face{font-family:"DotGothic16Local"')&&css.includes('--emoji-font:')&&css.includes('body,button,input,select,textarea{font-family:var(--dot-font)}')&&!css.includes(':root{--dot-font:"DotGothic16"')&&html.includes('id="customEmojiCursor"'),'Bundled Japanese dot font is overridden or emoji-specific isolation is missing');
|
||||
assert(!html.includes('所持ジェム')&&!functionSource('completionEffect').includes('ジェム')&&!functionSource('updateScoreLensBadge').includes('予想ジェム'),'Standalone gem terminology remains in the reward UI');
|
||||
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('purchaseStoreItem').includes('storeInventoryItems(meta,store).some'),'Store UI or purchase validation bypasses its seeded thirteen-item inventory');
|
||||
assert(functionSource('seededStoreItemIds').includes('.slice(0,12)')&&functionSource('seededStoreItemIds').includes('.slice(0,1)')&&functionSource('maybeOpenStore').includes('itemIds:seededStoreItemIds(meta.seed)'),'Stores do not persist twelve seeded cursors and one seeded non-cursor item');
|
||||
assert(functionSource('renderStorePanel').includes("{title:'アイテム'")&&functionSource('renderStorePanel').includes("{title:'カーソル'")&&functionSource('renderStorePanel').includes('if(category.cursor)card.append(icon,buy)')&&css.includes('.store-cursor-list{grid-template-columns:repeat(6'),'Shop is not split into item and horizontal twelve-cursor sections');
|
||||
assert(functionSource('renderInventoryPanel').includes("'inventory-cursor-grid'")&&functionSource('renderInventoryPanel').includes("option.classList.toggle('selected'")&&functionSource('useInventoryItemLoaded').includes("previousCursor===item.cursorStyle?'default':item.cursorStyle"),'Persistent click-to-toggle cursor inventory is missing');
|
||||
assert(functionSource('makeStaticBoard').includes("openStoreMeta(meta)")&&!functionSource('beginPan').includes('overviewShopAtClient'),'Distant overview still exposes a shop-only hit target instead of matching the minimap');
|
||||
assert(!app.includes('static-shop-icon')&&functionSource('beginPan').includes('nearestStoreMetaAtWorldPoint')&&!functionSource('beginPan').includes('overviewShopAtClient'),'Distant overview still exposes a shop-only hit target instead of matching the minimap');
|
||||
assert(functionSource('discardUnmovedCreatedPath').includes('path.cells.length!==1')&&functionSource('bindBoard').includes('discardUnmovedCreatedPath(b)'),'Cancelled pickup creation can leave an orphan handle');
|
||||
assert(functionSource('renderDragFrame').includes('refreshDragNumberColors(b)'),'Number colors do not update in the live pickup renderer');
|
||||
assert(functionSource('detachPathFromStartGate').includes('path.detachedStart=true')&&functionSource('renderBoardNow').includes("'data-endpoint-side':'start'")&&!app.includes('whitePickupEnd')&&!functionSource('finalizeAtGate').includes("'#fff'"),'Two-ended colored pickup support is incomplete');
|
||||
assert(!functionSource('openTipMergePlan').includes('a.detachedStart||o.detachedStart')&&functionSource('openTipMergePlan').includes('tipDistance!==0')&&!app.includes('function gateConnectionSteps'),'Same-cell pickup joining or exact-cell gate snapping is incomplete');
|
||||
assert(functionSource('updateScoreLensBadge').includes('projectedScoreForBoard')&&functionSource('scoreLensVisibleForMeta').includes('SCORE_LENS_ZOOM_THRESHOLD')&&functionSource('useInventoryItemLoaded').includes('data.scoreLensEnabled=!previous')&&/id:'score-lens'[^\n]+scoreLens:true[^\n]+icon:/.test(app)&&!app.includes('SCORE_LENS_RADIUS')&&css.includes('.score-lens-badge'),'Persistent ON/OFF score lens display is missing or still asks for a position');
|
||||
assert(functionSource('updateScoreLensBadge').includes('projectedScoreForBoard')&&functionSource('scoreLensVisibleForMeta').includes('SCORE_LENS_ZOOM_THRESHOLD')&&functionSource('useInventoryItemLoaded').includes('data.scoreLensEnabled=!previous')&&storeCatalog.find(item=>item.id==='score-lens')?.scoreLens===true&&/id:'score-lens'[^\n]+icon:/.test(app)&&!app.includes('SCORE_LENS_RADIUS')&&css.includes('.score-lens-badge'),'Persistent ON/OFF score lens display is missing or still asks for a position');
|
||||
assert(functionSource('renderStorePanel').includes('formatScore(price)')&&!functionSource('renderStorePanel').includes('price-data.score')&&!functionSource('purchaseStoreItem').includes('price-data.score'),'Store buttons do not always show the actual item price');
|
||||
assert(functionSource('zoomAt').includes('MIN_CAMERA_SCALE'),'Camera cannot zoom out to overview scale');
|
||||
assert(functionSource('addObstaclePattern').includes('puzzle.difficulty=sourcePuzzle.difficulty'),'Obstacle generation changes the displayed level and section constraint');
|
||||
|
|
|
|||
|
|
@ -3,19 +3,19 @@ const {vm,assert,functionSource,loadBendPuzzle,loadAppLogic}=require('./helpers/
|
|||
const BendPuzzle=loadBendPuzzle(),AppLogic=loadAppLogic();
|
||||
const context={
|
||||
AppLogic,
|
||||
SPECIAL_CELL_MIN_LEVEL:5,SPECIAL_CELL_DEBUG_ALL_LEVELS:false,data:{metas:{},specialMechanicsSeen:[]},
|
||||
SPECIAL_CELL_MIN_LEVEL:5,data:{metas:{},specialMechanicsSeen:[]},
|
||||
hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,
|
||||
deepClone:value=>JSON.parse(JSON.stringify(value)),key2:(x,y)=>`${x},${y}`,ckey:(r,c)=>`${r},${c}`,
|
||||
SIDE_D:{N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])
|
||||
SIDE_D:{N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},OPP:{N:'S',S:'N',W:'E',E:'W'},sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext([
|
||||
'specialCellSet','invalidateSpecialCellCaches','reservedSpecialKeys','cellSet','warpMap','warpPairForCell','isWarpTransition','pathCellsAdjacent','lockForDoor','pathHasLockKey','crossingKeys','pathIndexesAtCell','crossingStateAtCell','crossingsSatisfied','gateObj','outsidePoint','analyzeTurns','turnAnalysis','partialTurnCount','numbersForPath','specialPathValid','pathAxisAtCell','pathValid','usedGateSet','occupiedMap','isSolved','rebuildSolutionClues','addWarpSpecial','addLockSpecial','obstacleCellLimit','buildCrossingTemplate','addCrossingSpecial','recentSpecialMechanicTypes','addScheduledSpecial','specialCellUsage','addSpecialCellPattern'
|
||||
].map(name=>functionSource(name)).join('\n')+'\nthis.logic={specialCellSet,turnAnalysis,pathValid,isSolved,addWarpSpecial,addLockSpecial,addCrossingSpecial,addSpecialCellPattern,isWarpTransition,crossingStateAtCell,crossingsSatisfied};',context);
|
||||
'specialCellSet','internalGateIndexes','internalGateIndexSet','isInternalGateIndex','invalidateSpecialCellCaches','reservedSpecialKeys','cellSet','warpMap','warpPairForCell','isWarpTransition','pathCellsAdjacent','lockForDoor','pathHasLockKey','crossingKeys','pathIndexesAtCell','crossingStateAtCell','crossingsSatisfied','gateObj','outsidePoint','analyzeTurns','turnAnalysis','partialTurnCount','numbersForPath','specialPathValid','pathAxisAtCell','pathValid','usedGateSet','occupiedMap','isSolved','rebuildSolutionClues','addWarpSpecial','addLockSpecial','obstacleCellLimit','buildCrossingTemplate','addCrossingSpecial','addInternalGateSpecial','recentSpecialMechanicTypes','addScheduledSpecial','specialCellUsage','addSpecialCellPattern'
|
||||
].map(name=>functionSource(name)).join('\n')+'\nthis.logic={specialCellSet,turnAnalysis,pathValid,isSolved,addWarpSpecial,addLockSpecial,addCrossingSpecial,addInternalGateSpecial,addSpecialCellPattern,isWarpTransition,crossingStateAtCell,crossingsSatisfied};',context);
|
||||
const logic=context.logic;
|
||||
for(let level=1;level<=4;level++)assert(AppLogic.specialSchedule(level,100,level).types.length===0,`Level ${level} scheduled a production special`);
|
||||
for(const level of[5,6]){const schedule=AppLogic.specialSchedule(level,100,level,['warp','lock','crossing'],[]);assert(schedule.types.length===1&&schedule.setCount===1,`Level ${level} did not schedule exactly one minimum set`)}
|
||||
const level7=AppLogic.specialSchedule(7,200,7,['warp','lock','crossing'],[]),level8=AppLogic.specialSchedule(8,200,8,['warp','lock','crossing'],[]);
|
||||
for(const level of[5,6]){const schedule=AppLogic.specialSchedule(level,100,level,['warp','lock','crossing','internalGate'],[]);assert(schedule.types.length===1&&schedule.setCount===1,`Level ${level} did not schedule exactly one minimum set`)}
|
||||
const level7=AppLogic.specialSchedule(7,200,7,['warp','lock','crossing','internalGate'],[]),level8=AppLogic.specialSchedule(8,200,8,['warp','lock','crossing','internalGate'],[]);
|
||||
assert(level7.setCount===2&&level8.setCount===3,'Level 7-8 special density does not increase deterministically');
|
||||
{
|
||||
const puzzle={valid:[[2,0],[2,1],[0,3],[0,4],[1,4],[2,4]],g:[[2,0,'W'],[2,4,'S']],n:[[0,4,1]],specialCells:{crossings:[],warps:[{a:[2,1],b:[0,3]}],locks:[]},solution:[]},path={startGate:0,endGate:1,openGate:null,cells:[[2,0],[2,1],[0,3],[0,4],[1,4],[2,4]]};
|
||||
|
|
@ -43,13 +43,13 @@ assert(base,'Could not find a puzzle supporting warp and key/door special cells'
|
|||
const warpedIndex=cached.solution.findIndex(path=>cached.specialCells.warps.some(pair=>path.cells.some(cell=>context.sameCell(cell,pair.a))));assert(warpedIndex>=0&&Number.isFinite(after[warpedIndex]),'Warp turn analysis did not run');
|
||||
}
|
||||
|
||||
for(let level=1;level<=4;level++){const low=logic.addSpecialCellPattern(base,0x700000+level,level),special=logic.specialCellSet(low);assert(!special.warps.length&&!special.locks.length&&!special.crossings.length,`Level ${level} generated a production special`)}
|
||||
for(let level=1;level<=4;level++){const low=logic.addSpecialCellPattern(base,0x700000+level,level),special=logic.specialCellSet(low);assert(low===base&&!special.internalGates.length&&!special.warps.length&&!special.locks.length&&!special.crossings.length,`Level ${level} received a special mechanic before the production threshold`)}
|
||||
const counts={warp:0,lock:0,cross:0,boards:0};let examples={};
|
||||
for(let seed=0;seed<900;seed++){
|
||||
context.data.specialMechanicsSeen=[];
|
||||
context.data.specialMechanicsSeen=['internalGate'];
|
||||
const puzzle=logic.addSpecialCellPattern(base,0x710000+seed,5);if(!puzzle)continue;const special=logic.specialCellSet(puzzle),
|
||||
present=[special.warps.length&&'warp',special.locks.length&&'lock',special.crossings.length&&'cross'].filter(Boolean);
|
||||
assert(present.length===1&&puzzle.specialSchedule?.introduction===true&&puzzle.specialSchedule?.setCount===1,'Level-5 first encounter was not one minimum special type');
|
||||
assert(special.internalGates.length===0&&present.length===1&&puzzle.specialSchedule?.introduction===true&&puzzle.specialSchedule?.setCount===1,'Level-5 first encounter did not schedule exactly one production special');
|
||||
if(special.warps.length){counts.warp++;examples.warp||=puzzle}
|
||||
if(special.locks.length){counts.lock++;examples.lock||=puzzle}
|
||||
if(special.crossings.length){counts.cross++;examples.cross||=puzzle}
|
||||
|
|
@ -57,6 +57,12 @@ for(let seed=0;seed<900;seed++){
|
|||
}
|
||||
for(const [type,count] of Object.entries({warp:counts.warp,lock:counts.lock}))assert(count>0,`${type} was never selected by deterministic first-encounter scheduling`);
|
||||
assert(counts.boards>0,'Level-5 scheduling generated no viable special boards');
|
||||
for(let seed=0;seed<900&&!examples.internalGate;seed++){
|
||||
context.data.specialMechanicsSeen=['warp','lock','crossing'];
|
||||
const puzzle=logic.addSpecialCellPattern(base,0x720000+seed,5);
|
||||
if(puzzle&&logic.specialCellSet(puzzle).internalGates.length===1)examples.internalGate=puzzle;
|
||||
}
|
||||
assert(examples.internalGate,'Internal gates were not introduced by the normal level-5 production schedule');
|
||||
|
||||
function verifyPuzzle(puzzle){
|
||||
const special=logic.specialCellSet(puzzle),valid=new Set(puzzle.valid.map(cell=>context.ckey(...cell))),crossingKeys=new Set(special.crossings.map(cell=>context.ckey(...cell))),covered=new Map();
|
||||
|
|
@ -71,6 +77,7 @@ function verifyPuzzle(puzzle){
|
|||
assert(covered.size===valid.size,'Special solution does not cover every valid cell');
|
||||
for(const pair of special.warps){let found=false;for(const path of puzzle.solution){const ai=path.cells.findIndex(cell=>context.sameCell(cell,pair.a)),bi=path.cells.findIndex(cell=>context.sameCell(cell,pair.b));if(ai>=0||bi>=0){assert(Math.abs(ai-bi)===1,'Warp endpoints are not consecutive');found=true}}assert(found,'Warp pair is absent from the solution')}
|
||||
for(const lock of special.locks){let found=false;for(const path of puzzle.solution){const ki=path.cells.findIndex(cell=>context.sameCell(cell,lock.key)),di=path.cells.findIndex(cell=>context.sameCell(cell,lock.door));if(ki>=0||di>=0){assert(ki>=0&&di>ki,'Key is not before its door on the solution line');found=true}}assert(found,'Key/door pair is absent from the solution')}
|
||||
for(const pair of special.internalGates||[]){const a=puzzle.g[pair.a],b=puzzle.g[pair.b],da=context.SIDE_D[a[2]],db=context.SIDE_D[b[2]];assert(context.manhattan(a,b)===1&&a[0]+da[0]===b[0]&&a[1]+da[1]===b[1]&&b[0]+db[0]===a[0]&&b[1]+db[1]===a[1],'Internal gate pair is not adjacent and mutually facing');assert(puzzle.solution.some(path=>path.startGate===pair.a||path.endGate===pair.a)&&puzzle.solution.some(path=>path.startGate===pair.b||path.endGate===pair.b),'Internal gate pair is not used as solution endpoints')}
|
||||
const state={paths:context.deepClone(puzzle.solution),specialProgress:{crossings:special.crossings.map(cell=>context.ckey(...cell))}};assert(logic.isSolved(state,puzzle),'Generated special-cell solution does not solve its board');
|
||||
for(const cell of special.crossings){const key=context.ckey(...cell),validSet=new Set(puzzle.valid.map(candidate=>context.ckey(...candidate))),gateSet=new Set(puzzle.g.map(g=>context.ckey(g[0],g[1])));assert(covered.get(key)===2,'Crossing solution does not use the crossing cell exactly twice');assert(!gateSet.has(key),'Crossing cell was placed on a gate cell');for(const neighbor of[[cell[0]-1,cell[1]],[cell[0]+1,cell[1]],[cell[0],cell[1]-1],[cell[0],cell[1]+1]])assert(validSet.has(context.ckey(...neighbor)),'Crossing cell was placed on the board edge')}
|
||||
}
|
||||
|
|
@ -85,4 +92,4 @@ for(const puzzle of Object.values(examples))verifyPuzzle(puzzle);
|
|||
solvedState.paths[1].cells=[[0,2],[1,2],[1,1],[2,1],[3,1]];solvedState.specialProgress.crossings=['2,2'];assert(!logic.isSolved(solvedState,solvedPuzzle),'Crossing history permitted clear after the crossing state was removed');
|
||||
}
|
||||
|
||||
console.log(`Special-cell generation passed: ${counts.warp} warp, ${counts.lock} key/door, ${counts.cross} crossing first-encounter boards`);
|
||||
console.log(`Special-cell generation passed: ${counts.warp} warp, ${counts.lock} key/door, ${counts.cross} crossing first-encounter boards, plus production-scheduled internal gates`);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
'use strict';
|
||||
const {app,assert,functionSource,loadAppLogic}=require('./helpers/app-source');
|
||||
const AppLogic=loadAppLogic();
|
||||
const placeSource=functionSource('placeChildAtFrontier'),expandSource=functionSource('expandMetaNow'),metaStateSource=functionSource('metaState'),snapshotSource=functionSource('snapshotForStorage');
|
||||
const placeSource=functionSource('placeChildAtFrontier'),expandSource=functionSource('expandMetaNow'),metaStateSource=functionSource('metaState'),ensureMetaStateSource=functionSource('ensureMetaState'),snapshotSource=functionSource('snapshotForStorage');
|
||||
for(const forbidden of ['ensureBoards','renderAll','updateHud','nextPaint','save(','rebuildOccupancy'])assert(!placeSource.includes(forbidden),`Child placement still performs ${forbidden}`);
|
||||
assert((expandSource.match(/refreshWorldView\(/g)||[]).length===1,'Expansion does not use one presentation/persistence commit');
|
||||
for(const forbidden of ['ensureBoards','renderAll','updateHud'])assert(!expandSource.includes(forbidden),`Expansion bypasses the shared view pipeline with ${forbidden}`);
|
||||
assert(metaStateSource.includes('normalizeState(state)'),'Live state normalization is not delegated to normalizeState');
|
||||
assert(!metaStateSource.includes('normalizeState')&&!metaStateSource.includes('markStateDirty')&&ensureMetaStateSource.includes('normalizeState(state)'),'State reads are not pure or explicit state repair is not delegated to normalizeState');
|
||||
assert(!functionSource('puzzleOf').includes('markMetaDirty')&&functionSource('repairPuzzleDifficulty').includes('markMetaDirty'),'Puzzle reads still dirty metadata or difficulty repair lacks an explicit command boundary');
|
||||
assert(snapshotSource.includes('metaRowsForStorage()')&&snapshotSource.includes('stateRowsForStorage()'),'Snapshot serialization bypasses shared row serializers');
|
||||
assert(functionSource('pushCloudPending').includes('cloudRowsForStorage')&&functionSource('cloudRowsForStorage').includes('metaForStorage')&&functionSource('cloudRowsForStorage').includes('stateForStorage'),'Cloud serialization bypasses full-detail row serializers');
|
||||
assert(functionSource('stateForStorage').includes('AppLogic.stateForStorage'),'State serialization is not delegated to the shared module');
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
const http=require('http');
|
||||
const path=require('path');
|
||||
const {spawn}=require('child_process');
|
||||
const {chromium}=require('playwright');
|
||||
const {chromium}=require('playwright-core');
|
||||
const {assert,root}=require('./helpers/app-source');
|
||||
|
||||
const port=61000+Math.floor(Math.random()*1000);
|
||||
|
|
@ -93,7 +93,7 @@ async function waitForServer(){
|
|||
if(!result.fontReady)console.log('Store UI diagnostics:',JSON.stringify(result));
|
||||
assert(result.flags===262,'Flag cursor catalog is incomplete');
|
||||
assert(result.fontReady&&/DotGothic16Local/.test(result.bodyFont)&&/DotGothic16Local/.test(result.buttonFont)&&/DotGothic16Local/.test(result.numberFont),'Bundled Japanese dot font did not load or was overridden');
|
||||
assert(result.faceMin===500&&result.faceMax===50000&&result.flagBase===10000&&result.oecdBase===20000&&Math.abs(result.shopChance-1/30)<1e-12,'Cursor prices or the 1/30 shop chance are incorrect');
|
||||
assert(result.faceMin===500&&result.faceMax===50000&&result.flagBase===10000&&result.oecdBase===20000&&Math.abs(result.shopChance-1/10)<1e-12,'Cursor prices or the 1/10 shop chance are incorrect');
|
||||
assert(result.meta==='店主:UI TEST'&&!result.meta.includes('価格は固定'),'Fixed-price shop copy remains');
|
||||
assert(result.headings.join('|')==='アイテム|カーソル','Shop sections are not separated');
|
||||
assert(result.items===2&&result.cursors===12,'Shop does not render its 2+12 inventory');
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ const {assert,functionSource,app,css,html}=require('./helpers/app-source');
|
|||
const root=path.resolve(__dirname,'..'),catalog=JSON.parse(fs.readFileSync(path.join(root,'store-catalog.json'),'utf8'));
|
||||
assert(functionSource('positionBoardLabel').includes('viewportRect')&&functionSource('applyCamera').includes('repositionActiveBoardHud'),'Board HUD is not repositioned against viewport bounds');
|
||||
assert(css.includes('.fps-stat{position:fixed')&&css.includes('left:calc(6px')&&css.includes('bottom:calc(5px')&&functionSource('refreshFpsCounter').includes('FPS 待機'),'FPS display is not bottom-left or idle-aware');
|
||||
assert(functionSource('pointerEventSamples').includes('coalesced[coalesced.length-1]')&&functionSource('appendBoardPointerSamples').includes('setBoardPointerSample')&&functionSource('setBoardPointerSample').includes('b.pendingPointerMove=sample')&&functionSource('renderDragFrame').includes('renderGeometryRevision'),'Knob dragging still processes stale samples or recomputes static geometry every frame');
|
||||
assert(functionSource('pointerEventSamples').includes('coalesced[coalesced.length-1]')&&functionSource('appendBoardPointerSamples').includes('setBoardPointerSample')&&functionSource('setBoardPointerSample').includes('scheduler.push(sample)')&&functionSource('renderDragFrame').includes('renderGeometryRevision'),'Knob dragging still processes stale samples or recomputes static geometry every frame');
|
||||
assert(functionSource('makeBoard').includes("class:'board-input-surface'")&&css.includes('.board-input-surface{fill:transparent;cursor:crosshair;pointer-events:fill}')&&css.includes('.board-svg{cursor:crosshair;pointer-events:none}'),'Board input remains active across overlapping transparent SVG padding');
|
||||
assert(css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px')&&css.includes('body[data-cursor-mode="dom"] *{cursor:none!important}')&&functionSource('updateCustomCursorFromPointer').includes("dataset.cursorMode==='dom'"),'Custom cursor size or UI-wide DOM fallback is missing');
|
||||
assert(catalog.find(item=>item.id==='score-lens')?.cost===200000&&app.includes("id:'score-lens',name:'ジェムレンズ',cost:200000"),'Gem lens base price is not 200000');
|
||||
assert(catalog.find(item=>item.id==='score-lens')?.cost===200000&&app.includes("id:'score-lens'")&&app.includes('CanonicalStoreCatalog.map'),'Gem lens base price is not sourced from the canonical catalog');
|
||||
assert(app.includes('TIME_ATTACK_MINUTES=Object.freeze([3,5,10])')&&html.includes('data-time-minutes="3"')&&html.includes('data-time-minutes="5"')&&html.includes('data-time-minutes="10"'),'Time attack durations are not 3, 5, and 10 minutes');
|
||||
assert(css.includes('.time-attack-panel{width:min(760px')&&html.includes('📋 結果をコピー')&&functionSource('timeAttackResultText').includes('⏱️'),'Time attack UI/result decoration is incomplete');
|
||||
console.log('v47.71 HUD, input, cursor, economy, and time-attack regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,loadBendPuzzle,root,fs,path}=require('./helpers/app-source');
|
||||
assert(app.includes("const APP_VERSION='47.77'")&&html.includes('v47.77'),'Version was not advanced to v47.77');
|
||||
const {assert,functionSource,app,css,html,read,loadBendPuzzle,root,fs,path,buildMeta}=require('./helpers/app-source');
|
||||
assert(buildMeta.APP_VERSION==='47.83'&&buildMeta.PACKAGE_VERSION==='47.83.0','Version was not advanced to v47.83');
|
||||
assert(functionSource('makeBoard').includes('board-input-clip-')&&functionSource('renderBoardNow').includes("'clip-path':`url(#${b.inputClipId})`") ,'Endpoint hit regions are not clipped to their owning board');
|
||||
assert(functionSource('sharedBoundaryColorIndex').includes('boundaryColorSource')&&functionSource('renderBoardNow').includes('sharedBoundaryColorIndex(meta,i)'),'Facing gate colors are not unified');
|
||||
assert(functionSource('renderBoardNow').includes('else renderDragFrame(b)'),'Stationary drag cursor is not restored after a full board redraw');
|
||||
assert(functionSource('bindBoard').includes('b.drawing=null;b.armedGate=null')&&!functionSource('bindBoard').includes('b.drawing.pointerId=e.pointerId'),'Released drawings can still be resumed from unrelated cells');
|
||||
assert(functionSource('radialReactionIndex').includes('bestDistance')&&functionSource('beginReactionGesture').includes('setPointerCapture')&&app.includes("window.addEventListener('pointermove',moveReactionGesture,true)"),'Radial hold-drag-release selection is incomplete');
|
||||
assert(html.includes('id="settingsBtn"')&&html.includes('id="settingsPlayerName"')&&html.includes('id="lightweightRenderingToggle"')&&html.includes('id="soundEnabledToggle"')&&functionSource('saveSettings').includes('/api/cloud/profile')&&functionSource('init').includes('createAutomaticPlayerName'),'Settings-based player naming, rendering, sound, or automatic initial name is missing');
|
||||
assert(html.includes('id="settingsBtn"')&&html.includes('id="settingsPlayerName"')&&html.includes('id="lightweightRenderingToggle"')&&html.includes('id="soundEnabledToggle"')&&functionSource('saveSettings').includes('commitPlayerProfileName')&&functionSource('commitPlayerProfileName').includes('/api/cloud/profile')&&functionSource('init').includes('createAutomaticPlayerName'),'Settings-based player naming, rendering, sound, or automatic initial name is missing');
|
||||
assert(functionSource('soundTone').includes('if(!uiSettings.soundEnabled)return')&&functionSource('applyUiSettings').includes("classList.toggle('lightweight-rendering'"),'Settings are not applied to sound and rendering');
|
||||
assert(functionSource('renderBoardNow').includes('st.solvedBy!==LEGACY_LOCAL_SOLVER'),'Legacy 「あなた」 solver labels are still displayed');
|
||||
assert(css.includes('.solver-badge strong{display:block;max-width:100%;white-space:nowrap')&&css.includes('text-overflow:ellipsis'),'Solver names can still wrap repeatedly');
|
||||
assert(functionSource('positionBoardLabel').includes("b.label.style.width=innerWidth+'px'"),'North/south board HUD does not extend to the board edge');
|
||||
assert(functionSource('positionBoardLabel').includes('viewportRect.height-margin-labelHeight')&&functionSource('makeBoard').includes('boardHudLayer?.append(label)'),'Board HUD is not detached and clamped to the viewport');
|
||||
assert(css.includes('#customEmojiCursor.flag-cursor img{position:absolute;left:50%;top:50%')&&css.includes('transform:translate(-50%,-50%)'),'Flag cursor is not centered');
|
||||
assert(css.includes('.fps-stat{position:fixed')&&css.includes('background:none')&&css.includes('font-size:8px'),'FPS display is not subtle and background-free');
|
||||
assert(html.includes('長くつながって太くなった線ほど高得点です。'),'Help does not explain long/thick-line scoring');
|
||||
assert(functionSource('rebuildMinimapWorld').includes('drawMapBoardCells')&&!functionSource('drawMapBoardCells').includes('stroke('),'Minimap board outlines are still drawn');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!app.includes('function drawWorldOverviewShops('),'Zoomed-out rendering does not match the minimap');
|
||||
assert(functionSource('maybeGrantGenerationFailureBonus').includes('GENERATION_FAILURE_MIN_MS')&&functionSource('maybeGrantGenerationFailureBonus').includes('unresolvedExpansionCandidates(meta).length'),'Generation failure bonus is not limited to long unresolved expansion attempts');
|
||||
const server=read('server.js');assert(server.includes("row.state.expanded===true")&&server.includes("Expansion already succeeded")&&server.includes('GENERATION_FAILURE_BONUS = 2500'),'Generation bonus server guard or level-6-equivalent amount is missing');
|
||||
const server=`${read('server.js')}\n${read('server/player-service.js')}`;assert(server.includes("row.state.expanded===true")&&server.includes("Expansion already succeeded")&&server.includes('GENERATION_FAILURE_BONUS = 2500'),'Generation bonus server guard or level-6-equivalent amount is missing');
|
||||
const internal=read('docs/internal-system.md');assert(internal.includes('Do not add changelog')&&internal.includes('update the relevant current specification in place'),'Documentation policy does not prohibit update-history documents');
|
||||
for(const name of fs.readdirSync(path.join(root,'docs')))assert(!/(changelog|release|history|update|v\d+|phase\d+)/i.test(name),`Historical update document remains: ${name}`);
|
||||
const BendPuzzle=loadBendPuzzle();let hasLevel10Region=false,hasLevel6Region=false;
|
||||
|
|
@ -26,4 +26,4 @@ assert(hasLevel6Region&&hasLevel10Region,'World difficulty map still lacks level
|
|||
const high=BendPuzzle.generatePuzzle([[0,0],[1,0],[0,1]],987654,6,12,-9);
|
||||
assert(high.difficulty===6&&high.complexity.rawRating>=6,'Level-6 regional generation is still discarded or misclassified');
|
||||
assert(functionSource('normalizeStoredPuzzle').includes('solverDifficulty(puzzle,targetLevel)'),'Stored high-level boards lose their regional level after reload');
|
||||
console.log('v47.77 settings, map rendering, generation bonus, docs policy, and high-level generation regression test passed');
|
||||
console.log('v47.83 settings, map rendering, generation bonus, docs policy, and high-level generation regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css}=require('./helpers/app-source');
|
||||
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('selectBoard').includes('setActiveBoard(b.id)'),'Board HUD does not persist on the selected board');
|
||||
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)')&&functionSource('selectBoard').includes('setActiveBoard(b.id)')&&!functionSource('selectBoard').includes('hudBoardId=b.id'),'Board HUD is not pinned by committed knob manipulation');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('{showLevels:true}')&&functionSource('drawMapBoardCells').includes('showLevels&&!solved')&&functionSource('rebuildMinimapWorld').includes('drawMapBoardCells(base,visibleMetas,mapX,mapY,scale)'),'Overview levels are missing or leaked into the minimap');
|
||||
assert(functionSource('directionFromDelta').includes("preferredAxis!=='V'")&&!functionSource('directionFromDelta').includes('largest<smallest*POINTER_DOMINANT_RATIO'),'Diagonal pointer motion is still rejected');
|
||||
assert(functionSource('extendPointerTo').includes('from=boardCellCenter(path.cells[path.cells.length-1])'),'Diagonal traversal still starts from a stale raw pointer location');
|
||||
|
|
@ -8,4 +8,4 @@ assert(functionSource('renderDragFrame').includes("setSvgAttr(cache.liveTail,'x2
|
|||
assert(functionSource('renderDragFrame').includes('blended=false')&&functionSource('renderBoardNow').includes('blended=!uiSettings.lightweightRendering')&&functionSource('pathColorAtCell').includes('uiSettings.lightweightRendering'),'Lightweight rendering still blends line colors');
|
||||
assert(functionSource('refreshDragNumberColors').includes('drawing.dragNumberKeys')&&!functionSource('refreshDragNumberColors').includes('for(const[key,node]of b.numberNodes'),'Drag number styling still scans every number each step');
|
||||
assert(css.includes('.drag-tip-group{pointer-events:none;will-change:transform;transform-origin:0 0}')&&css.includes('body.lightweight-rendering .path{filter:none!important;mix-blend-mode:normal!important}'),'Drag transform or lightweight path styles are missing');
|
||||
console.log('v47.75 selected HUD, cached overview, diagonal drag, and drag performance regression test passed');
|
||||
console.log('v47.83 active HUD, cached overview, diagonal drag, and drag performance regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app}=require('./helpers/app-source');
|
||||
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('selectBoard').includes("classList.add('hud-current')"),'HUD must remain attached to the selected board');
|
||||
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)')&&!functionSource('selectBoard').includes('hudBoardId=b.id'),'HUD must be activated by manipulation and remain on that board after release');
|
||||
assert(functionSource('drawWorldOverview').includes('overviewCache')&&functionSource('positionCachedWorldOverview').includes('overviewCanvas.style.transform')&&functionSource('drawWorldOverview').includes('drawImage(overviewBase'),'Overview panning must move a cached raster layer without viewport-sized frame copies');
|
||||
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells'),'Overview cache rebuild is missing');
|
||||
assert(!functionSource('queueRealtimeCursor').includes('schedulePresenceRender'),'Local cursor movement must not repaint the remote-presence canvas');
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css}=require('./helpers/app-source');
|
||||
const {assert,functionSource,app,css,buildMeta}=require('./helpers/app-source');
|
||||
const fs=require('fs'),path=require('path'),frameScheduler=fs.readFileSync(path.join(__dirname,'../client/input/frame-scheduler.js'),'utf8');
|
||||
for(const name of ['scheduleBoardDragFrame','queueCameraInteraction','scheduleWorldOverview','schedulePresenceRender','scheduleReactionRender']){
|
||||
const source=functionSource(name);
|
||||
if(name==='queueCameraInteraction')assert(source.includes('CAMERA_DISPLAY_WATCHDOG_MS')&&source.includes('cancelAnimationFrame')&&source.includes('clearTimeout'),'Camera missed-vsync fallback does not cancel its paired scheduler');
|
||||
else if(name==='scheduleBoardDragFrame')assert(source.includes('DRAG_DISPLAY_WATCHDOG_MS')&&source.includes('cancelAnimationFrame')&&source.includes('clearTimeout'),'Pickup missed-vsync fallback does not cancel its paired scheduler');
|
||||
if(name==='queueCameraInteraction')assert(source.includes('cameraInteractionScheduler.push')&&frameScheduler.includes('clearArmed()'),'Camera missed-vsync fallback does not cancel its paired scheduler');
|
||||
else if(name==='scheduleBoardDragFrame')assert(source.includes('requestFrame')&&frameScheduler.includes('clearArmed()'),'Pickup missed-vsync fallback does not cancel its paired scheduler');
|
||||
else if(name!=='scheduleWorldOverview')assert(!source.includes('setTimeout('),`${name} still double-throttles through setTimeout plus requestAnimationFrame`);
|
||||
assert(source.includes('requestAnimationFrame'),`${name} must remain frame-synchronized`);
|
||||
if(name==='scheduleBoardDragFrame'||name==='queueCameraInteraction')assert(frameScheduler.includes('requestFrame(step)'),`${name} must use the shared frame-synchronized lane`);
|
||||
else assert(source.includes('requestAnimationFrame'),`${name} must remain frame-synchronized`);
|
||||
}
|
||||
const minimap=functionSource('scheduleMinimap');
|
||||
assert(minimap.includes("classList?.contains?.('is-interacting')"),'Minimap must not repaint during an active gesture');
|
||||
assert(minimap.includes("interactionActive('overview')"),'Minimap must not repaint during an active gesture');
|
||||
const overview=functionSource('drawWorldOverview');
|
||||
assert(functionSource('positionCachedWorldOverview').includes('overviewCanvas.style.transform'),'Overview panning must move the cached bitmap as a compositor layer');
|
||||
assert(functionSource('scheduleWorldOverview').includes('requestIdleCallback'),'Overview cache rebuilds must be deferred to idle time');
|
||||
|
|
@ -18,8 +20,8 @@ const camera=functionSource('applyCamera');
|
|||
assert(camera.includes('translate3d('),'Nearby field panning must use a compositor transform');
|
||||
assert(camera.includes('shiftOnlineLayersForCamera'),'Online canvases must move without full repaint during camera gestures');
|
||||
assert(camera.includes('minimapDirty=true'),'Camera movement must mark, not immediately repaint, the minimap');
|
||||
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id'),'Selected-board HUD persistence changed unexpectedly');
|
||||
assert(css.includes('#viewport.panning::after{display:none}'),'Full-screen vignette must be suppressed while panning');
|
||||
assert(functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)'),'Persistent manipulated-board HUD gating changed unexpectedly');
|
||||
assert(!css.includes('#viewport.panning::after{display:none}')&&!css.includes('#world.camera-interacting'),'Panning still activates an automatic lightweight visual mode');
|
||||
assert(css.includes('#presenceCanvas,#reactionCanvas{transform-origin:0 0'),'Online canvases are not compositor-ready');
|
||||
assert(app.includes("const APP_VERSION='47.77'"),'Application version was not advanced');
|
||||
console.log('v47.77 compositor frame-pipeline regression test passed');
|
||||
assert(buildMeta.APP_VERSION==='47.83','Application version was not advanced');
|
||||
console.log('v47.83 compositor frame-pipeline regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css}=require('./helpers/app-source');
|
||||
const {assert,functionSource,app,css,buildMeta}=require('./helpers/app-source');
|
||||
const hud=functionSource('boardPlayHudVisible'),select=functionSource('selectBoard'),center=functionSource('centerMeta');
|
||||
assert(hud.includes('hudBoardId===b?.id'),'HUD must use explicit session selection, not drag/claim state');
|
||||
assert(select.includes('hudBoardId=b.id')&&select.includes("classList.add('hud-current')"),'Click selection must persist the board HUD');
|
||||
assert(hud.includes('hudBoardId===b?.id'),'HUD must remain visible for the last board whose knob was manipulated');
|
||||
assert(!select.includes('hudBoardId=b.id')&&functionSource('activateBoardHud').includes('hudBoardId=b.id')&&functionSource('activateBoardPointerDrag').includes('activateBoardHud(b)'),'Click selection must not expose the HUD, while a committed knob drag must pin it');
|
||||
assert(center.includes('select=true')&&app.includes("centerMeta(initialMeta||data.metas.B0,{select:false})"),'Startup camera centering must not select an untouched board');
|
||||
assert(functionSource('resetSelectedBoard').includes('rendered.get(hudBoardId)'),'Reset must target the persistent HUD selection');
|
||||
assert(!css.includes("body.is-drawing .board-card:not(.input-active) .static-layer"),'Non-selected boards must not be dimmed while drawing');
|
||||
|
|
@ -10,11 +10,11 @@ const sanitize=functionSource('sanitizeStateForPuzzle');
|
|||
assert(sanitize.includes('path.detachedStart&&path.endGate==null')&&sanitize.includes('warpKeys.has'),'Loose lines with both knobs on warp cells must be removed');
|
||||
const realtime=functionSource('queueRealtimeCursor');
|
||||
assert(realtime.includes("classList.contains('is-drawing')")&&realtime.includes('realtimePendingCursorClient')&&realtime.indexOf('worldUnitAtClient')>realtime.indexOf('setTimeout'),'Raw pointer events must not perform realtime world conversion or send during drawing');
|
||||
const custom=functionSource('updateCustomCursorFromPointer');
|
||||
assert(custom.includes("dataset.cursorMode==='dom'")&&custom.includes('requestAnimationFrame')&&!custom.includes('FRAME_INTERVAL'),'DOM cursor fallback must remain visible during drag and commit at display cadence');
|
||||
assert(functionSource('syncCursorAppearance').includes('nativeSupported')&&css.includes('data-cursor-mode="native"'),'Static cursor skins must prefer the native cursor path');
|
||||
const customInput=functionSource('updateCustomCursorFromPointer'),customCommit=functionSource('commitCustomCursorFrame');
|
||||
assert(customInput.includes("dataset.cursorMode==='dom'")&&customInput.includes("classList.contains('is-drawing')")&&customInput.includes('scheduleCustomCursorFrame()')&&!customInput.includes('style.transform')&&customCommit.includes('style.transform')&&functionSource('scheduleCustomCursorFrame').includes('requestAnimationFrame(paint)'),'DOM cursor fallback must hide during knob manipulation and commit only through the capped display lane');
|
||||
assert(functionSource('syncCursorAppearance').includes('dataset.cursorMode=presentation.mode')&&css.includes('data-cursor-mode="dom"')&&css.includes('.board-input-surface')&&css.includes('body.is-drawing #viewport'),'Static cursor skins must cover uncleared board input surfaces and hide during manipulation');
|
||||
const drag=functionSource('extendPointerTo'),render=functionSource('renderDragFrame');
|
||||
assert(drag.includes('lastModelProbeKey')&&drag.includes('probeStep=6'),'Drag model work must be quantized instead of repeated for every raw move');
|
||||
assert(render.includes('blended=false')&&render.includes('lastRenderedTip')&&render.includes('setSvgAttr'),'Live drag rendering must avoid gradients and redundant SVG writes');
|
||||
assert(app.includes("const APP_VERSION='47.77'"),'Application version was not advanced');
|
||||
console.log('v47.77 HUD, warp cleanup, cursor, and drag performance regression test passed');
|
||||
assert(buildMeta.APP_VERSION==='47.83','Application version was not advanced');
|
||||
console.log('v47.83 active HUD, cursor coverage, and capped knob tracking regression test passed');
|
||||
|
|
|
|||
|
|
@ -1,73 +1,44 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,vm}=require('./helpers/app-source');
|
||||
const fs=require('fs'),path=require('path'),browserBenchmark=fs.readFileSync(path.join(__dirname,'browser-performance-benchmark.js'),'utf8');
|
||||
const fs=require('fs'),path=require('path'),browserBenchmark=fs.readFileSync(path.join(__dirname,'browser-performance-benchmark.js'),'utf8'),
|
||||
frameScheduler=fs.readFileSync(path.join(__dirname,'../client/input/frame-scheduler.js'),'utf8'),
|
||||
dragModule=fs.readFileSync(path.join(__dirname,'../client/input/drag.js'),'utf8');
|
||||
|
||||
assert(app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60')&&app.includes('DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS')&&app.includes('CAMERA_DISPLAY_WATCHDOG_MS=18'),'Interaction and auxiliary frame budgets are not separated');
|
||||
assert(app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60')&&app.includes('DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS')&&app.includes('CAMERA_DISPLAY_WATCHDOG_MS=34'),'Interaction and auxiliary frame budgets are not separated');
|
||||
assert(app.includes('BEND_INTERACTION_SCHEDULER')&&app.includes('bend-field-interaction-scheduler-variant')&&app.includes('batteryDischargingTime'),'Scheduler rollout variant or battery telemetry guardrail is missing');
|
||||
assert(app.includes('DRAG_MAX_CATCHUP_CELLS=24')&&functionSource('extendPointerTo').includes('catchupPending')&&functionSource('processBoardDragFrame').includes('catchupPending'),'Large pointer jumps are not bounded and resumed across drag frames');
|
||||
assert(app.includes('DRAG_MAX_LIVE_CATCHUP_CELLS=6')&&app.includes('DRAG_MAX_RELEASE_CATCHUP_CELLS=24')&&functionSource('extendPointerTo').includes('catchupPending')&&functionSource('processBoardDragFrame').includes('catchupPending'),'Large pointer jumps are not bounded and resumed across drag frames');
|
||||
|
||||
const cursor=functionSource('updateCustomCursorFromPointer'),cursorSync=functionSource('syncCursorAppearance');
|
||||
assert(cursor.includes('requestAnimationFrame')&&!cursor.includes('FRAME_INTERVAL'),'DOM cursor fallback is not synchronized to every display frame');
|
||||
const cursorInput=functionSource('updateCustomCursorFromPointer'),cursorCommit=functionSource('commitCustomCursorFrame'),cursorSync=functionSource('syncCursorAppearance');
|
||||
assert(cursorInput.includes('scheduleCustomCursorFrame()')&&!cursorInput.includes('customEmojiCursor.style.transform')&&cursorCommit.includes('customEmojiCursor.style.transform=`translate3d(')&&functionSource('scheduleCustomCursorFrame').includes('customCursorLastDraw+DRAG_FRAME_INTERVAL')&&functionSource('scheduleCustomCursorFrame').includes('requestAnimationFrame(paint)')&&!cursorCommit.includes('Math.exp('),'DOM cursor does not coalesce the latest pointer sample through its display-backed 60 Hz commit lane');
|
||||
assert(app.includes("addEventListener('pointermove',updateCustomCursorFromPointer")&&app.includes("addEventListener('pointerrawupdate',updateCustomCursorFromPointer"),'DOM cursor does not tolerate sparse raw-input delivery');
|
||||
assert(cursor.includes('customCursorActiveUntil=perfNow()+40')&&cursor.includes('customCursorFrame=requestAnimationFrame(step)')&&cursor.includes('Math.exp(-elapsed/8)'),'DOM cursor does not keep and smooth its display-rAF loop between active pointer events');
|
||||
assert(!cursor.includes("classList.contains('is-drawing')")&&!css.includes('body.is-drawing #customEmojiCursor'),'Custom cursor is hidden during pickup dragging');
|
||||
assert(cursorSync.includes('nativeSupported')&&cursorSync.includes('--active-native-cursor')&&css.includes('data-cursor-mode="native"'),'Native cursor assets or the DOM fallback switch are missing');
|
||||
|
||||
const cursorClasses=new Set(),cursorFrames=[],cursorRafQueue=[],cursorTransforms=[],cursorPerfSamples=new Map();
|
||||
let cursorClock=0,cursorRafId=0;
|
||||
const cursorContext={
|
||||
document:{body:{dataset:{cursorMode:'dom'}}},
|
||||
customEmojiCursor:{classList:{contains:name=>cursorClasses.has(name),add:name=>cursorClasses.add(name),remove:name=>cursorClasses.delete(name)},style:{set transform(value){cursorTransforms.push(value)}}},
|
||||
perfNow:()=>cursorClock,
|
||||
requestAnimationFrame:callback=>{cursorRafQueue.push(callback);return++cursorRafId},
|
||||
markVisualFrame:timestamp=>cursorFrames.push(timestamp),
|
||||
perfObserve:(name,value)=>{const samples=cursorPerfSamples.get(name)||[];samples.push(value);cursorPerfSamples.set(name,samples);return value},
|
||||
perfCount:()=>{}
|
||||
};
|
||||
vm.createContext(cursorContext);
|
||||
vm.runInContext(`const interactionCommitAt=Object.create(null),interactionInputAt=Object.create(null);${functionSource('recordInteractionCommit')}let customCursorFrame=0,customCursorX=0,customCursorY=0,customCursorInputAt=0,customCursorActiveUntil=0,customCursorRenderedX=NaN,customCursorRenderedY=NaN,customCursorLastFrameAt=0,customCursorInputRevision=0,customCursorCommittedRevision=0;${cursor};this.updateCustomCursorFromPointer=updateCustomCursorFromPointer;`,cursorContext);
|
||||
let cursorEventAt=0;
|
||||
for(let frameAt=1000/60;frameAt<=1100;frameAt+=1000/60){
|
||||
while(cursorEventAt<=1000&&cursorEventAt<=frameAt+.001){
|
||||
cursorClock=cursorEventAt;cursorContext.updateCustomCursorFromPointer({clientX:cursorEventAt,clientY:cursorEventAt/2,timeStamp:cursorEventAt||.001,pointerType:'mouse',target:{isConnected:true}});
|
||||
cursorEventAt+=1000/30;
|
||||
}
|
||||
cursorClock=frameAt;
|
||||
for(const callback of cursorRafQueue.splice(0))callback(frameAt);
|
||||
}
|
||||
const cursorFrameGaps=cursorFrames.slice(1).map((timestamp,index)=>timestamp-cursorFrames[index]).sort((a,b)=>a-b),
|
||||
cursorMedianGap=cursorFrameGaps[Math.floor((cursorFrameGaps.length-1)*.5)],
|
||||
sortedCursorAges=[...(cursorPerfSamples.get('cursorInputAge')||[])].sort((a,b)=>a-b),cursorP95Age=sortedCursorAges[Math.floor((sortedCursorAges.length-1)*.95)],
|
||||
measuredCursorGaps=[...(cursorPerfSamples.get('cursorFrameGap')||[])].sort((a,b)=>a-b),measuredCursorMedianGap=measuredCursorGaps[Math.floor((measuredCursorGaps.length-1)*.5)],
|
||||
changedCursorTransforms=cursorTransforms.filter((value,index)=>index===0||value!==cursorTransforms[index-1]).length;
|
||||
assert(cursorFrames.length>=60&&cursorMedianGap<=20,`Warm DOM cursor loop produced ${cursorFrames.length} frames with a ${cursorMedianGap} ms median gap`);
|
||||
assert(measuredCursorGaps.length>=30&&measuredCursorMedianGap<=20,`Production cursor instrumentation captured ${measuredCursorGaps.length} gaps with a ${measuredCursorMedianGap} ms median`);
|
||||
assert(changedCursorTransforms>=55,`Warm DOM cursor loop changed its transform on only ${changedCursorTransforms} display frames`);
|
||||
assert(sortedCursorAges.length>=25,`Production cursor instrumentation captured only ${sortedCursorAges.length} fresh-input latency samples`);
|
||||
assert(cursorP95Age<25,`Warm DOM cursor loop produced ${cursorP95Age} ms p95 input age`);
|
||||
assert(cursorInput.includes("classList.contains('is-drawing')")&&css.includes('body.is-drawing #customEmojiCursor'),'Custom cursor is not hidden during pickup dragging');
|
||||
assert(cursorSync.includes('dataset.cursorMode=presentation.mode')&&!cursorSync.includes('nativeSupported')&&!css.includes('data-cursor-mode="native"'),'Custom cursor skins are not forced through the board-safe DOM renderer');
|
||||
|
||||
const cameraQueue=functionSource('queueCameraInteraction'),cameraApply=functionSource('applyCamera');
|
||||
assert(cameraQueue.includes('pendingCameraInteraction=next')&&cameraQueue.includes('requestAnimationFrame')&&cameraQueue.includes('CAMERA_DISPLAY_WATCHDOG_MS')&&cameraQueue.includes('cancelAnimationFrame')&&cameraQueue.includes('cameraInteractionLastDraw+DRAG_FRAME_INTERVAL'),'Camera does not use a latest-input 60 Hz queue with a missed-vsync watchdog');
|
||||
assert(cameraQueue.includes('pendingCameraInteraction=next')&&cameraQueue.includes('cameraInteractionScheduler.push(next)')&&app.includes('watchdogDelay:CAMERA_DISPLAY_WATCHDOG_MS')&&frameScheduler.includes('requestFrame(step)'),'Camera does not use a latest-input 60 Hz queue with a missed-vsync watchdog');
|
||||
assert(functionSource('zoomAt').includes('queueCameraInteraction')&&!functionSource('zoomAt').includes('applyCamera(')&&app.includes("window.addEventListener('pointermove',movePan,true)")&&app.includes("window.addEventListener('pointerup',stopPan,true)")&&app.includes("viewport.addEventListener('lostpointercapture',stopPan,true)"),'Wheel zoom bypasses the capped camera lane or pan lifecycle is not resilient outside the viewport');
|
||||
assert(cameraApply.includes('positionCachedWorldOverview')&&functionSource('positionCachedWorldOverview').includes('translate3d'),'Overview position is not updated in the camera fast path');
|
||||
assert(functionSource('scheduleWorldOverview').includes('requestIdleCallback')&&functionSource('scheduleWorldOverview').includes("classList.contains('is-interacting')"),'Overview rebuilds are not idle and interaction-safe');
|
||||
assert(functionSource('scheduleWorldOverview').includes('requestIdleCallback')&&functionSource('scheduleWorldOverview').includes("interactionActive('overview')"),'Overview rebuilds are not idle and interaction-safe');
|
||||
|
||||
const dragSchedule=functionSource('scheduleBoardDragFrame'),dragFrame=functionSource('processBoardDragFrame');
|
||||
assert(dragSchedule.includes('requestAnimationFrame')&&dragSchedule.includes('DRAG_DISPLAY_WATCHDOG_MS')&&dragSchedule.includes('cancelAnimationFrame')&&dragSchedule.includes('clearTimeout')&&dragSchedule.includes('b.dragVisualLastFrameAt+DRAG_FRAME_INTERVAL')&&dragSchedule.includes('INTERACTION_FRAME_TOLERANCE_MS')&&dragFrame.includes('DRAG_FRAME_INTERVAL'),'Pickup visuals or logic are not capped to their 60 Hz lane');
|
||||
assert(functionSource('queueBoardPointerMove').includes('appendBoardPointerSamples')&&functionSource('queueBoardPointerMove').includes('commitBoardDragFromInputDeadline')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&!functionSource('queueBoardPointerMove').includes('updatePickupHandleOverlay')&&!functionSource('queueBoardPointerMove').includes('markVisualFrame')&&!functionSource('queueBoardPointerMove').includes('extendPointerTo'),'Pointer events mutate presentation or model work outside the capped scheduler');
|
||||
assert(functionSource('commitBoardDragFromInputDeadline').includes('b.dragVisualLastFrameAt+DRAG_FRAME_INTERVAL')&&functionSource('commitBoardDragFromInputDeadline').includes('INTERACTION_FRAME_TOLERANCE_MS')&&functionSource('commitBoardDragFromInputDeadline').includes('processBoardDragFrame(b,now)'),'Pickup input deadline fallback is not governed by the 60 Hz presentation ceiling');
|
||||
assert(dragSchedule.includes('requestFrame')&&functionSource('ensureBoardDragScheduler').includes('watchdogDelay:DRAG_DISPLAY_WATCHDOG_MS')&&functionSource('ensureBoardDragScheduler').includes('interval:DRAG_FRAME_INTERVAL')&&dragModule.includes("'idle','armed','running','draining','settling','cancelled'"),'Pickup visuals or logic are not capped to their 60 Hz lane');
|
||||
assert(functionSource('queueBoardPointerMove').includes('appendBoardPointerSamples')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&!functionSource('queueBoardPointerMove').includes('updatePickupHandleOverlay')&&!functionSource('queueBoardPointerMove').includes('markVisualFrame')&&!functionSource('queueBoardPointerMove').includes('extendPointerTo'),'Pointer events still directly mutate pickup presentation or perform forbidden model work');
|
||||
assert(!app.includes('function commitBoardDragFromInputDeadline('),'Pickup still has a direct input-deadline commit path outside the shared 60 Hz scheduler');
|
||||
assert(!app.includes('DRAG_INPUT_RESCUE_MS')&&!app.includes('function rescuePickupVisualFromInput('),'An input-rate pickup presentation path can still exceed 60 Hz');
|
||||
assert(functionSource('setBoardPointerSample').includes('dragVisualActiveUntil=perfNow()+40')&&dragFrame.includes('Math.exp(-visualElapsed/8)')&&dragFrame.includes('updateDrawingHandlePosition')&&dragFrame.includes('perfNow()<b.dragVisualActiveUntil'),'Pickup visual lane does not remain warm and ease toward sparse pointer samples at display cadence');
|
||||
assert(functionSource('setBoardPointerSample').includes('dragVisualActiveUntil=perfNow()+40')&&dragFrame.includes('b.dragVisualX=move.clientX')&&dragFrame.includes('updateDrawingHandlePosition')&&dragFrame.includes('perfNow()<b.dragVisualActiveUntil')&&!dragFrame.includes('visualBlend'),'Pickup visual lane does not track the latest pointer sample directly');
|
||||
assert(functionSource('renderDragFrame').includes('translate3d')&&functionSource('renderDragFrame').includes('tailNow-drawing.lastTailRenderAt>=32'),'Complex pickup handles are not compositor-driven or the SVG live tail is repainted every display frame');
|
||||
assert(dragFrame.includes('updatePickupHandleOverlay')&&dragFrame.includes('usesLightweightDragOverlay')&&dragFrame.includes('dirtyBoards.delete(b)')&&app.includes('LIGHTWEIGHT_DRAG_BOARD_CELLS=120')&&functionSource('updatePickupHandleOverlay').includes('translate3d')&&css.includes('#pickupHandleOverlay{position:fixed'),'Large-board pickup still repaints the SVG during the gesture instead of using the lightweight display-rate handle');
|
||||
assert(functionSource('activateBoardPointerDrag').includes('setPickupScenePresentation(b,true)')&&functionSource('setLightweightDragPresentation').includes("style.opacity=next?'0':''")&&functionSource('setPickupScenePresentation').includes('setLightweightDragPresentation(board,next&&board===activeBoard)')&&functionSource('scheduleInteractionSettlePresentation').includes('setPickupScenePresentation(null,false)'),'Active large-board decorative layers are not compositor-suppressed during pickup and restored after settlement');
|
||||
assert(dragFrame.includes("recordInteractionCommit('pickupVisual'")&&dragFrame.includes('workDuration'),'Pickup cadence, input age, or callback work is not instrumented');
|
||||
assert(dragFrame.includes('timestamp<=b.dragVisualActiveUntil')&&dragFrame.includes("recordInteractionCommit('pickupVisual',timestamp,move.inputAt,false)")&&css.includes('transition:transform 16.67ms linear'),'Pickup sample gaps are not compositor-interpolated or warm visual ticks are not measured');
|
||||
assert(dragFrame.includes('timestamp<=b.dragVisualActiveUntil')&&dragFrame.includes("recordInteractionCommit('pickupVisual',timestamp,move.inputAt,false)")&&!css.includes('transition:transform 16.67ms linear'),'Pickup warm visual ticks are not measured or the knob overlay still adds transition lag');
|
||||
assert(dragFrame.includes('freshVisualInput')&&dragFrame.includes('freshLogicalInput'),'Pickup latency instrumentation resamples stale input on warm display frames');
|
||||
assert(app.includes('DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4')&&app.includes('DRAG_MODEL_BUDGET_MS=.5')&&dragFrame.includes('b.pointerMoveSamples.shift()')&&dragFrame.includes('perfNow()-modelStarted<DRAG_MODEL_BUDGET_MS')&&dragFrame.includes('logicalSlotDue&&(freshLogicalInput||velocity[0]||velocity[1]||b.drawing?.catchupPending||b.pointerMoveSamples?.length)'),'Pickup logic does not time-bound ordered-sample catch-up or warm frames still run model traversal without pending work');
|
||||
assert(functionSource('setBoardPointerSample').includes('trimBoardPointerSamples(samples)')&&functionSource('trimBoardPointerSamples').includes('leastTurn')&&functionSource('trimBoardPointerSamples').includes('samples.splice(removeIndex,1)'),'Bounded pickup input overflow does not preserve sharp turns');
|
||||
assert(app.includes('DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4')&&app.includes('DRAG_MODEL_BUDGET_MS=.5')&&dragFrame.includes('scheduler.takeLogical(1)')&&dragFrame.includes('perfNow()-modelStarted<DRAG_MODEL_BUDGET_MS')&&dragFrame.includes('logicalSlotDue&&(freshLogicalInput||velocity[0]||velocity[1]||b.drawing?.catchupPending||scheduler.hasLogical())'),'Pickup logic does not time-bound ordered-sample catch-up or warm frames still run model traversal without pending work');
|
||||
assert(functionSource('ensureBoardDragScheduler').includes('trim:trimBoardPointerSamples')&&functionSource('trimBoardPointerSamples').includes('leastTurn')&&functionSource('trimBoardPointerSamples').includes('samples.splice(removeIndex,1)'),'Bounded pickup input overflow does not preserve sharp turns');
|
||||
assert(dragFrame.includes('pointerInsideBoardScreen(b,logicalMove)?eventToSvg')&&functionSource('pointerInsideBoardScreen').includes('boardScreenRect'),'Off-board edge panning can still traverse and prematurely finish the pickup path');
|
||||
assert(dragFrame.includes("perfEnd('pickupModelWork'")&&dragFrame.includes("perfEnd('pickupVisualWork'"),'Pickup model and visual callback costs are not independently instrumented');
|
||||
assert(functionSource('clearDragRender').includes("style.display='none'")&&!functionSource('clearDragRender').includes('replaceChildren'),'Drag cache nodes are destroyed between gestures');
|
||||
const releaseDrain=functionSource('processBoardPointerReleaseDrain'),releaseFlush=functionSource('flushBoardPointerMove');
|
||||
assert(releaseDrain.includes('DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME')&&releaseDrain.includes('DRAG_MODEL_BUDGET_MS')&&releaseDrain.includes('DRAG_MAX_RELEASE_CATCHUP_CELLS')&&releaseDrain.includes('scheduleBoardPointerReleaseDrain(b)')&&!releaseFlush.includes('Infinity'),'Pointer release does not drain final intent in bounded frame slices');
|
||||
|
||||
const bind=functionSource('bindBoard');
|
||||
assert(bind.indexOf('beginPendingClaimPointer')<bind.indexOf('await ensureBoardClaimForInput'),'Shared-session pickup preview does not start before the ownership round-trip');
|
||||
|
|
@ -76,18 +47,19 @@ assert(bind.includes('activateBoardPointerDrag')&&functionSource('activateBoardP
|
|||
assert(functionSource('selectBoard').includes('alreadySelected')&&functionSource('renderDragFrame').includes('pickupStartFullBoardRenders'),'Pickup start does not avoid and measure full-board redraws');
|
||||
assert(functionSource('applyBoardCommand').includes('pointerInteraction&&b.drawing?.pointerId==null')&&bind.includes('b.drawing=null;b.armedGate=null;refreshInteractionState()')&&bind.includes('scheduleBoardCommandSettlement(b,{paint:true,invalidate:true,pathIndex:finishedPathIndex})'),'Pickup completion does not end interaction state before deferring broad render and cache work');
|
||||
assert(functionSource('safeRelease').includes('scheduleInteractionSettlePresentation')&&!functionSource('safeRelease').includes('queueMicrotask')&&functionSource('scheduleInteractionSettlePresentation').includes('requestAnimationFrame')&&!functionSource('scheduleInteractionSettlePresentation').includes('ensureBoards()'),'Pickup release still runs broad settlement work inside the pointer task');
|
||||
assert(functionSource('finalizeAtGate').includes('deferSettlement:true')&&functionSource('scheduleBoardCommandSettlement').includes('requestAnimationFrame')&&functionSource('scheduleBoardCommandSettlement').includes("classList.contains('is-interacting')"),'Pickup completion still performs render, persistence, or solve settlement inside the active drag callback');
|
||||
assert(functionSource('finalizeAtGate').includes('deferSettlement:true')&&functionSource('scheduleBoardCommandSettlement').includes('requestAnimationFrame')&&functionSource('scheduleBoardCommandSettlement').includes("interactionActive('persistence')"),'Pickup completion still performs render, persistence, or solve settlement inside the active drag callback');
|
||||
assert(functionSource('finalizeAtGate').includes('usesLightweightDragOverlay(b)')&&functionSource('finalizeAtGate').includes('requestAnimationFrame(()=>gateConnectEffect'),'Large-board gate decoration still mutates the SVG inside the finishing interaction task');
|
||||
assert(bind.includes("flushBoardPointerMove(b,e,()=>finishPointer(e,'settled'))")&&bind.includes('b.releaseDrain?.pointerId===e.pointerId'),'Pointer-up settlement does not wait for the bounded drain or lost capture can cancel an active drain');
|
||||
|
||||
assert(functionSource('runDeferredSave').includes("classList?.contains('is-interacting')"),'Ordinary persistence is not deferred during interactions');
|
||||
assert(functionSource('runDeferredSave').includes("interactionActive('persistence')"),'Ordinary persistence is not deferred during interactions');
|
||||
assert(functionSource('applyWorldSignal').includes('await waitForInteractionSettle()')&&functionSource('pullCloudWorld').includes('await waitForInteractionSettle()'),'Cross-tab or cloud reconciliation can still run broad refresh work during a gesture');
|
||||
assert(functionSource('createPuzzleWorker').includes("perfCount('workerResultsDeferredDuringInteraction')")&&functionSource('createPuzzleWorker').includes('waitForInteractionSettle().then(deliver)'),'Puzzle-worker promise continuations can still run during an active gesture');
|
||||
assert(functionSource('createPuzzleWorker').includes("perfCount('workerResultsDeferredDuringInteraction')")&&functionSource('createPuzzleWorker').includes("waitForInteractionSettle(null,'worker').then(deliver)"),'Puzzle-worker promise continuations can still run during an active gesture');
|
||||
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET')&&!functionSource('ensureBoards').includes('makeStaticBoard(meta)'),'Visible puzzles can still be hidden behind a clicked-only summary LOD');
|
||||
assert(functionSource('observeInteractionFrame').includes('workDuration>slowWorkThreshold')&&!functionSource('observeInteractionFrame').includes('elapsed>24'),'Quality fallback still mistakes frame spacing for callback overload');
|
||||
assert(functionSource('refreshInteractionState').includes('interactionQualityDowngradePending'),'Quality changes are not deferred until the gesture ends');
|
||||
assert(!app.includes('interactionQualityDowngradePending')&&!app.includes('autoReducedEffects')&&functionSource('applyUiSettings').includes("classList.toggle('reduced-effects',uiSettings.lightweightRendering)"),'Interaction still activates an automatic lightweight visual mode');
|
||||
assert(functionSource('refreshInteractionState').includes("topbar?.classList.toggle('drawing-active'")&&functionSource('refreshInteractionState').includes("world?.classList.toggle('camera-interacting'")&&functionSource('refreshInteractionState').includes('!usesLightweightDragOverlay(drawingBoard)')&&!css.includes('body.is-interacting .gate-dot')&&!css.includes('body.is-drawing #topbar'),'Pickup styling still invalidates the entire document or a lightweight large-board subtree');
|
||||
assert(functionSource('recordInteractionCommit').includes('InputAge')&&functionSource('observeInteractionFrame').includes('interactionDroppedFrameRatio')&&app.includes("perfCount('longTasks')"),'Interaction latency, dropped-frame, or long-task diagnostics are missing');
|
||||
assert(functionSource('ensureBoards').includes("classList?.contains?.('is-interacting')")&&functionSource('ensureBoards').includes('lodPassesDeferredDuringInteraction'),'Detailed and overview LOD work is not deferred during gestures');
|
||||
assert(functionSource('ensureBoards').includes("interactionActive('world')")&&functionSource('ensureBoards').includes('lodPassesDeferredDuringInteraction'),'Detailed and overview LOD work is not deferred during gestures');
|
||||
|
||||
assert(functionSource('drawingForPath').includes('pathCellIndex:new Map')&&functionSource('extendOne').includes('pathCellIndex?.set'),'Long pickup paths do not retain a cell index');
|
||||
assert(functionSource('rewindActivePathToCell').includes('index===path.cells.length-1)return false'),'A pickup hovering on its current tip is incorrectly classified as a geometry-changing rewind');
|
||||
|
|
@ -118,6 +90,21 @@ const validLongCells=new Set(longCells.map(([r,c])=>`${r},${c}`)),forward=traver
|
|||
assert(forward.length===249&&forward[248][1]===249,'A 250-cell pointer segment does not preserve every forward grid cell');
|
||||
let remainingCatchup=forward.length,batches=0,maxBatch=0;while(remainingCatchup){const batch=Math.min(24,remainingCatchup);maxBatch=Math.max(maxBatch,batch);remainingCatchup-=batch;batches++}
|
||||
assert(maxBatch===24&&batches===11,'A 250-cell catch-up is not bounded to constant-size frame batches');
|
||||
{
|
||||
let progress=0,extendCalls=0,completed=0,scheduled=0;
|
||||
const releaseContext={
|
||||
DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME:4,DRAG_MODEL_BUDGET_MS:.5,DRAG_MAX_RELEASE_CATCHUP_CELLS:24,
|
||||
perfStart:()=>0,perfNow:()=>0,perfEnd:()=>0,pointerInsideBoardScreen:()=>true,eventToSvg:(_board,move)=>[move.clientX,move.clientY],cellAt:(_board,point)=>[0,point[0]],
|
||||
extendPointerTo:board=>{extendCalls++;progress+=Math.min(24,250-progress);board.drawing.catchupPending=progress<250},
|
||||
usesLightweightDragOverlay:()=>false,renderDragFrame:()=>{},recordInteractionCommit:()=>{},perfCount:()=>{},
|
||||
scheduleBoardPointerReleaseDrain:()=>{scheduled++;return true},ensureBoardDragScheduler:()=>({beginSettling:()=>true}),queueMicrotask:callback=>callback()
|
||||
};
|
||||
vm.createContext(releaseContext);vm.runInContext(`${releaseDrain}\nthis.processBoardPointerReleaseDrain=processBoardPointerReleaseDrain;`,releaseContext);
|
||||
const drain={pointerId:1,moves:[{pointerId:1,clientX:250,clientY:0,inputAt:1}],index:0,inputAt:1,tracedIndexes:new Set(),onComplete:()=>completed++},
|
||||
board={drawing:{pointerId:1,catchupPending:false},lastFlushPointerCells:[],logicalPointerCellTrace:[],releaseDrain:drain};
|
||||
while(board.releaseDrain){const before=extendCalls;releaseContext.processBoardPointerReleaseDrain(board,drain,extendCalls*17);assert(extendCalls-before<=4,'A release frame exceeded its logical-sample work bound')}
|
||||
assert(progress===250&&extendCalls===11&&completed===1&&scheduled===2,'Bounded release drain lost, duplicated, or incompletely settled the final 250-cell intent');
|
||||
}
|
||||
|
||||
const endpointState={paths:Array.from({length:100},(_,index)=>({endGate:null,detachedStart:index%2===0,cells:[[0,index],[1,index]]}))},
|
||||
endpointContext={Map,ckey:(r,c)=>`${r},${c}`,metaState:()=>endpointState};
|
||||
|
|
|
|||
23
test/v4779-settings-pan-hud-smoke-test.js
Normal file
23
test/v4779-settings-pan-hud-smoke-test.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,vm,buildMeta}=require('./helpers/app-source');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
assert(html.includes('id="resetSettings"')&&html.includes('id="closeSettings"')&&!html.includes('id="saveSettings"'),'Settings actions must expose Initial reset and Close without a Save button');
|
||||
assert(functionSource('closeSettings').includes('saveSettings(restoreFocus)')&&functionSource('saveSettings').includes('closeSettingsDialog(restoreFocus)')&&functionSource('resetSettingsForm').includes('lightweightRenderingToggle.checked=false'),'Close does not apply settings or Initial reset is incomplete');
|
||||
assert(functionSource('applyUiSettings').includes("classList.toggle('lightweight-rendering',uiSettings.lightweightRendering)")&&functionSource('applyUiSettings').includes("classList.toggle('reduced-effects',uiSettings.lightweightRendering)")&&!app.includes('autoReducedEffects')&&!app.includes('interactionQualityDowngradePending'),'Panning/interaction still enables an automatic lightweight mode');
|
||||
assert(!css.includes('#viewport.panning::after{display:none}')&&!css.includes('#world.camera-interacting')&&css.includes('body.lightweight-rendering #noiseCanvas'),'Pan-only lightweight CSS remains or the explicit setting was removed');
|
||||
assert(html.includes('id="boardHudLayer"')&&functionSource('makeBoard').includes('boardHudLayer?.append(label)')&&functionSource('positionBoardLabel').includes('viewportRect.height-margin-labelHeight')&&css.includes('.board-label[hidden]{display:none!important}'),'Active board HUD is still board-clipped or not viewport-clamped');
|
||||
assert(!functionSource('hudPlacementCandidates').includes('occupancy.get'),'HUD placement still rejects overlap with neighboring puzzles');
|
||||
assert(functionSource('positionCachedWorldOverview').includes('cam.scale/Math.max')&&functionSource('positionCachedWorldOverview').includes('translate3d')&&functionSource('positionCachedWorldOverview').includes('scale(${ratio})')&&functionSource('applyCamera').includes('positionCachedWorldOverview()'),'Zoomed-out overview does not move/scale its cached camera layer during pan');
|
||||
assert(functionSource('updateDrawingHandlePosition').includes('point[0],point[1]')&&functionSource('processBoardDragFrame').includes('else if(b.drawing?.pointerId===move.pointerId)renderDragFrame(b)'),'Dragged knob is not tied directly to the latest pointer position');
|
||||
assert(functionSource('syncCursorAppearance').includes('dataset.cursorMode=presentation.mode')&&!functionSource('updateCustomCursorFromPointer').includes('customEmojiCursor.style.transform')&&functionSource('commitCustomCursorFrame').includes('customEmojiCursor.style.transform=`translate3d(')&&css.includes('body.is-drawing #customEmojiCursor{display:none!important}'),'Custom cursor coverage, capped tracking, or drag-time hiding regressed');
|
||||
|
||||
const overviewContext={overviewCanvas:{hidden:true,style:{}},overviewCache:{scale:.2,baseWidth:1000,baseHeight:800,anchorX:0,anchorY:0,unit:43},cam:{scale:.1},MIN_CAMERA_SCALE:.01,currentCenter:[2,3],getViewportRect:()=>({width:800,height:600}),inWorldOverview:()=>true,perfCount:()=>{}};
|
||||
overviewContext.cameraCenterInChunks=()=>overviewContext.currentCenter;vm.createContext(overviewContext);vm.runInContext(`${functionSource('positionCachedWorldOverview')}this.positionCachedWorldOverview=positionCachedWorldOverview;`,overviewContext);
|
||||
overviewContext.positionCachedWorldOverview();const overviewTransformBefore=overviewContext.overviewCanvas.style.transform;overviewContext.currentCenter=[5,-1];overviewContext.positionCachedWorldOverview();
|
||||
assert(overviewTransformBefore!==overviewContext.overviewCanvas.style.transform&&overviewTransformBefore.includes('scale(0.5)'),'Cached overview transform does not react to camera movement and scale');
|
||||
const hudStyle={removeProperty(name){delete this[name]}},hudLabel={hidden:true,classList:{add(){}},style:hudStyle,offsetWidth:220,offsetHeight:44,dataset:{}},hudContext={boardHudLayer:{},MIN_CAMERA_SCALE:.01,cam:{scale:.6},CELL:43,PAD:26,UNIT:215,boardPlayHudVisible:()=>true,hudPlacementCandidates:()=>[{side:'N',dx:0,dy:0,priority:0}],getViewportRect:()=>({left:0,top:0,width:640,height:360}),boardScreenRect:()=>({left:140,top:-120,width:300,height:250})};
|
||||
vm.createContext(hudContext);vm.runInContext(`${functionSource('positionBoardLabel')}this.positionBoardLabel=positionBoardLabel;`,hudContext);const hudBoard={label:hudLabel,meta:{},p:{bounds:{w:5,h:5}}};
|
||||
assert(hudContext.positionBoardLabel(hudBoard)&&parseFloat(hudLabel.style.top)>=8&&parseFloat(hudLabel.style.top)<=308,'Detached HUD is not clamped inside the visible viewport');
|
||||
|
||||
console.log('v47.83 settings, pan rendering, detached HUD, cursor, and knob regression test passed');
|
||||
29
test/v4780-release-persistence-cursor-smoke-test.js
Normal file
29
test/v4780-release-persistence-cursor-smoke-test.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,vm,buildMeta}=require('./helpers/app-source');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
|
||||
const bindBoard=functionSource('bindBoard');
|
||||
assert(bindBoard.includes('const release=(paintCurrent=false)=>{')&&bindBoard.includes('cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay()'),'Pointer release does not synchronously stop the drag scheduler and remove the pickup overlay');
|
||||
assert(functionSource('hidePickupHandleOverlay').includes("removeProperty('transform')"),'Pickup overlay retains its released compositor position');
|
||||
|
||||
const pickupDesign=functionSource('syncPickupHandleDesign'),pickupUpdate=functionSource('updatePickupHandleOverlay');
|
||||
assert(pickupDesign.includes("classList.toggle('custom-cursor',Boolean(item))")&&pickupDesign.includes("classList.toggle('flag-cursor',Boolean(item?.flagAsset))")&&pickupDesign.includes('pickupHandleOverlay.replaceChildren(image)')&&pickupUpdate.includes('syncPickupHandleDesign()'),'Pickup overlay does not mirror the selected emoji/flag cursor');
|
||||
assert(css.includes('#pickupHandleOverlay.custom-cursor.visible{display:grid}')&&css.includes('#pickupHandleOverlay.custom-cursor.flag-cursor img'),'Custom cursor pickup overlay CSS is missing');
|
||||
assert(functionSource('syncCursorAppearance').includes('syncPickupHandleDesign()'),'Changing cursor style does not refresh the pickup appearance');
|
||||
|
||||
const merge=functionSource('mergeSnapshotIntoData');
|
||||
assert(merge.includes('authoritativeCompatibleStateIds=authoritativeWorld?new Set():null')&&merge.includes('retainLocalSolve=compatible&¤t?.solved===true&&incoming?.solved!==true')&&merge.includes("if(retainLocalSolve)noteCloudRow('state',id)")&&merge.includes('merged=compatible?mergeBoardStates(current,incoming):deepClone(incoming)')&&!merge.includes('current?.solved&&!incoming.solved?deepClone(incoming)'),'Authoritative pull can still downgrade a compatible local clear or reuse a clear across replaced board geometry');
|
||||
const completion=functionSource('checkSolvedAndExpand');
|
||||
const cloudFailure=completion.slice(completion.indexOf('const published=await pushCloudPending()'),completion.indexOf('let durableMeta='));
|
||||
assert(completion.includes('writeDirtyRecoveryJournal();')&&cloudFailure.includes("toast('クリアは端末に保存しました。共有反映は自動で再試行します。')")&&cloudFailure.includes('armCloudPush(1000)')&&!cloudFailure.includes('data.states[b.id]=previous.state'),'Completion is not checkpointed immediately or is still rolled back on a transient cloud failure');
|
||||
|
||||
const overlay={dataset:{},classList:{values:new Set(),toggle(name,on){on?this.values.add(name):this.values.delete(name)}},replaceChildren(node){this.child=node;this.textContent=''},textContent:'',style:{}};
|
||||
const cursorItems=new Map([['emoji-test',{cursorStyle:'emoji-test',cursorEmoji:'🙂'}]]);
|
||||
const context={pickupHandleOverlay:overlay,data:{cursorStyle:'emoji-test'},cursorModel:{item:style=>cursorItems.get(style)||null},document:{createElement(){return{src:'',alt:'',draggable:true}}}};
|
||||
vm.createContext(context);vm.runInContext(`${functionSource('activeCustomCursorItem')}\n${pickupDesign}\nthis.syncPickupHandleDesign=syncPickupHandleDesign;`,context);
|
||||
assert(context.syncPickupHandleDesign()===true&&overlay.textContent==='🙂'&&overlay.classList.values.has('custom-cursor')&&!overlay.classList.values.has('flag-cursor'),'Emoji cursor was not applied to the pickup overlay');
|
||||
context.data.cursorStyle='flag-test';cursorItems.set('flag-test',{cursorStyle:'flag-test',flagAsset:'flag.svg'});context.syncPickupHandleDesign();
|
||||
assert(overlay.child?.src==='flag.svg'&&overlay.classList.values.has('flag-cursor'),'Flag cursor was not applied to the pickup overlay');
|
||||
|
||||
console.log('v47.83 pickup release, durable completion, and cursor-design regression test passed');
|
||||
24
test/v4781-hud-gate-overlay-smoke-test.js
Normal file
24
test/v4781-hud-gate-overlay-smoke-test.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,html,read,buildMeta}=require('./helpers/app-source');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
|
||||
const hud=functionSource('boardPlayHudVisible'),activateHud=functionSource('activateBoardHud'),select=functionSource('selectBoard'),activateDrag=functionSource('activateBoardPointerDrag'),makeBoard=functionSource('makeBoard');
|
||||
assert(hud.includes('hudBoardId===b?.id')&&!hud.includes('b?.drawing'),'HUD still disappears when the pointer draw ends');
|
||||
assert(activateHud.includes('hudBoardId=b.id')&&activateHud.includes('setBoardHudVisibility(previous,false)')&&activateDrag.includes('activateBoardHud(b)'),'Committed knob manipulation does not pin exactly one board HUD');
|
||||
assert(!select.includes('hudBoardId=b.id'),'Plain board selection exposes the HUD without knob manipulation');
|
||||
assert(makeBoard.includes("boardReset.addEventListener('click'")&&makeBoard.includes('resetSelectedBoard(b)'),'Detached HUD reset control is not directly wired after being moved outside the board card');
|
||||
assert(functionSource('beginPan').includes('button,a,input,select,textarea,[role=\"button\"]'),'Touch panning can still capture the detached HUD reset button');
|
||||
|
||||
const finalize=functionSource('finalizeAtGate'),overlay=functionSource('updatePickupHandleOverlay'),finish=functionSource('bindBoard');
|
||||
assert(finalize.includes('const pointerId=b?.drawing?.pointerId')&&finalize.includes('cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay()')&&finalize.includes('safeRelease(b.svg,pointerId)'),'Gate completion does not synchronously terminate the pointer-drag presentation');
|
||||
assert(overlay.includes('hidePickupHandleOverlay();return false'),'An invalidated drawing can leave the custom pickup cursor visible');
|
||||
assert(finish.includes('if(!b.drawing){cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay();safeRelease'),'Pointer-up fallback does not clear a stale pickup overlay');
|
||||
|
||||
const inventoryRender=functionSource('renderInventoryPanel'),inventorySync=functionSource('syncInventoryCursorSelection'),inventoryUse=functionSource('useInventoryItemLoaded');
|
||||
assert(inventoryRender.includes('option.dataset.itemId=item.id')&&inventoryRender.includes('event.preventDefault()'),'Cursor inventory options lack stable item identity or click-default suppression');
|
||||
assert(inventorySync.includes("querySelectorAll('.inventory-cursor-option[data-item-id]')")&&inventorySync.includes("classList.toggle('selected',selected)")&&inventorySync.includes("setAttribute('aria-pressed',String(selected))"),'Cursor selection cannot update in place');
|
||||
const cursorBranch=inventoryUse.slice(inventoryUse.indexOf('if(item.cursorStyle)'),inventoryUse.indexOf('if(item.scoreLens)'));
|
||||
assert(cursorBranch.includes('syncInventoryCursorSelection()')&&!cursorBranch.includes('renderInventoryPanel()')&&!cursorBranch.includes('updateHud()'),'Cursor switching still rebuilds the inventory panel and can move its scroll position');
|
||||
|
||||
console.log('v47.83 persistent board HUD, gate-overlay cleanup, and inventory-scroll regression test passed');
|
||||
38
test/v4782-audio-highlight-store-internal-gate-smoke-test.js
Normal file
38
test/v4782-audio-highlight-store-internal-gate-smoke-test.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'use strict';
|
||||
const {assert,functionSource,app,css,html,read,buildMeta}=require('./helpers/app-source');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
|
||||
const sound=functionSource('playSound');
|
||||
assert(app.includes('SOUND_GAIN_MULTIPLIER=5.2')&&functionSource('soundTone').includes('Math.min(.42')&&functionSource('soundNoise').includes('Math.min(.24'),'Requested sound level increase is missing');
|
||||
for(const kind of['grab','stretch','gate','clear','buy','reset','remove','shop','warp','key','error'])assert(sound.includes(`kind==='${kind}'`),`Missing distinct ${kind} sound`);
|
||||
assert(functionSource('finalizeAtGate').includes("playSound('gate')")&&functionSource('removeDetachedPathAtOwnEndpoint').includes("playSound('remove')")&&functionSource('beginMinimapPointer').includes("playSound('shop')"),'New sound variants are not wired to gameplay events');
|
||||
|
||||
const makeBoard=functionSource('makeBoard');
|
||||
assert(makeBoard.includes("class:'active-board-boundary-layer'")&&makeBoard.includes("class:'active-board-boundary-shadow'")&&makeBoard.includes("class:'active-board-boundary-dash'"),'Active-board boundary layers are missing');
|
||||
assert(css.includes('.board-card.hud-current:not(.solved) .active-board-boundary-layer{display:block}')&&css.includes('@keyframes activeBoardOrbit')&&css.includes('@keyframes activeBoardBreathe'),'Active-board orbit/blink styling is missing');
|
||||
assert(css.includes('body.lightweight-rendering .active-board-boundary-layer{display:none!important}'),'Active-board boundary remains visible in lightweight rendering');
|
||||
|
||||
assert(html.includes('<div class="modal-actions settings-actions"><button class="pill quiet" id="resetSettings"')&&html.includes('</button><button class="pill close" id="closeSettings"'),'Settings reset and close buttons are not adjacent in document order');
|
||||
assert(css.includes('.settings-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px}')&&css.includes('.settings-actions .pill{flex:1 1 0;width:auto;margin:0!important}'),'Settings actions are not horizontally aligned');
|
||||
|
||||
const remove=functionSource('removeDetachedPathAtOwnEndpoint'),bind=functionSource('bindBoard');
|
||||
assert(remove.includes("if(typeof renderBoardNow==='function')renderBoardNow(b)")&&bind.includes('renderBoardNow(b);scheduleBoardCommandSettlement'),'Line removal does not synchronously repaint before deferred settlement');
|
||||
assert(bind.includes('const release=(paintCurrent=false)=>{if(paintCurrent&&b.card?.isConnected)renderBoardNow(b)'),'Drag cleanup can still expose stale pre-removal geometry');
|
||||
|
||||
assert(makeBoard.includes('<small>ショップ</small>')&&makeBoard.includes('<em></em>'),'Cyber-pop shop markup is missing');
|
||||
assert(css.includes('repeating-linear-gradient(90deg,#ff45c5')&&css.includes('.line-store .shop-shell em{'),'Cyber-pop shop appearance is missing');
|
||||
const minimap=functionSource('rebuildMinimapWorld'),overview=functionSource('rebuildWorldOverviewCache'),minimapPointer=functionSource('beginMinimapPointer'),pan=functionSource('beginPan');
|
||||
assert(minimap.includes('drawMapStores')&&overview.includes('drawMapStores'),'Shop positions are not rendered in both minimap and zoomed-out overview');
|
||||
assert(minimapPointer.includes('nearestStoreMetaAtWorldPoint')&&minimapPointer.includes('openStoreMeta(storeMeta)')&&pan.includes('nearestStoreMetaAtWorldPoint')&&pan.includes('openStoreMeta(storeMeta)'),'Shop markers are not clickable from minimap and simplified overview');
|
||||
assert(!html.includes('minimapStatus')&&functionSource('drawMinimap').includes('店舗マーカーをクリックするとショップを開きます。'),'Minimap still exposes counts or lacks shop instructions');
|
||||
|
||||
assert(!app.includes('INTERNAL_GATE_DEBUG_ALL_LEVELS')&&functionSource('addSpecialCellPattern').includes('AppLogic.specialSchedule'),'Internal gates are not governed solely by the production special-mechanic schedule');
|
||||
const addInternal=functionSource('addInternalGateSpecial'),gatePoint=functionSource('gatePoint'),outside=functionSource('outsidePoint');
|
||||
assert(addInternal.includes('special.internalGates.push({a:aIndex,b:bIndex})')&&addInternal.includes('p.solution.splice(chosen.pathIndex,1,prefix,suffix)'),'Internal gates do not split a normal solution path into operable gate endpoints');
|
||||
assert(addInternal.includes('const clueable=[prefix,suffix].every')&&addInternal.includes('special.internalGates.pop()'),'Internal-gate generation does not retry unsafe zero-clue splits');
|
||||
assert(gatePoint.includes('if(g.internal)return[x,y]')&&outside.includes('if(g?.internal)return null'),'Internal gates are not rendered/analysed as in-board endpoints');
|
||||
assert(makeBoard.includes("'aria-label':g.internal?`盤面内ゲート ${i+1}`")&&makeBoard.includes('gateHitBox(gp,g.side,g.internal)'),'Internal gates do not share normal gate input affordances');
|
||||
for(const name of['matchingNeighborGate','gateFrontierCandidates','missingGateConnections','placementConnectionRequirements'])assert(functionSource(name).includes('internalGates'),`${name} can misclassify an internal gate as a world-expansion gate`);
|
||||
|
||||
console.log('v47.83 audio, active-board emphasis, shop navigation, removal repaint, and internal-gate regression test passed');
|
||||
44
test/v4783-map-store-economy-persistence-smoke-test.js
Normal file
44
test/v4783-map-store-economy-persistence-smoke-test.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use strict';
|
||||
const vm=require('vm');
|
||||
const {assert,functionSource,app,css,html,read,buildMeta}=require('./helpers/app-source');
|
||||
const server=read('server.js');
|
||||
const packageVersion=JSON.parse(read('package.json')).version;
|
||||
assert(packageVersion===buildMeta.PACKAGE_VERSION&&buildMeta.APP_VERSION==='47.83','v47.83 canonical version marker is missing');
|
||||
|
||||
assert(html.includes('<div class="minimap-head"><b>マップ</b></div>')&&!html.includes('minimapStatus')&&!html.includes('周辺マップ'),'Map title or count removal is incomplete');
|
||||
assert(html.includes('<span class="shop">ショップ</span>')&&functionSource('makeBoard').includes('<small>ショップ</small>'),'Shop labels were not localized');
|
||||
assert(!functionSource('drawMinimap').includes('nearbyPlayers')&&!functionSource('drawMinimap').includes('visibleMetas.length')&&!functionSource('drawMinimap').includes('storeCount||0'),'Map still exposes player, board, or shop counts');
|
||||
assert(css.includes('.active-board-boundary-dash{stroke:#f5feff;stroke-width:9;stroke-dasharray:1 18'),'Active-board orbit dots were not thickened to the shadow scale');
|
||||
|
||||
const renderDrag=functionSource('renderDragFrame'),overlay=functionSource('updatePickupHandleOverlay');
|
||||
assert(functionSource('activeDrawingLineColorIndex').includes('startColorIndex')&&overlay.includes('activeDrawingLineColorIndex(path)'),'Pickup overlay color is not derived from the actively drawn line');
|
||||
assert(renderDrag.includes('color=startColor')&&renderDrag.includes('colorChanged=drawing.lastRenderedColor!==color')&&renderDrag.includes('drawing.lastRenderedColor=color'),'Repeated grabs can retain a stale knob color');
|
||||
|
||||
const maybeStore=functionSource('maybeOpenStore'),normalizeStore=functionSource('normalizeStore'),renderBoard=functionSource('renderBoardNow');
|
||||
assert(app.includes('STORE_CHANCE=1/10')&&server.includes('const STORE_CHANCE=1/10'),'Client/server shop appearance probability is not three times the former 1/30 rate');
|
||||
assert(functionSource('storeObstacleCell').includes('puzzleOf(meta).obstacles')&&maybeStore.includes('cell=storeObstacleCell(meta)')&&maybeStore.includes('pathIndex:-1'),'Shop generation is not anchored to a former obstacle cell');
|
||||
assert(normalizeStore.includes('directCell')&&functionSource('storeCellForMeta').includes('store?.cell')&&functionSource('summarizeBoardV2').includes('store?.cell'),'Obstacle-site shop coordinates are not durable');
|
||||
assert(server.includes('meta?.puzzle?.obstacles||[]')&&server.includes('pathIndex:-1,cellIndex:-1,cell')&&functionSource('sanitizeStateForPuzzle').includes('st.store.cell=obstacleCell')&&functionSource('makeBoard').includes('obstacleNodes.set')&&renderBoard.includes("key===ckey(...storeCell)?'none':''"),'The replaced obstacle remains visible under the shop');
|
||||
|
||||
assert(functionSource('personalEconomyMode').includes('cloudProfile'),'Personal economy mode is not stable across transient connectivity changes');
|
||||
for(const name of['inventoryEntries','spentScoreTotal','pruneAndCount'])assert(functionSource(name).includes('personalEconomyMode()'),`${name} can fall back to shared-board purchases during an outage`);
|
||||
assert(functionSource('purchaseStoreItem').includes("personalEconomyMode()&&!onlinePlayerEconomy()")&&functionSource('purchaseStoreItem').includes('共有の所持数を確認できないため購入できません。'),'Offline personal purchases are not blocked safely');
|
||||
assert(functionSource('mergeGlobalRecords').includes('playerEarnedScore=Math.max')&&functionSource('applyPlayerEconomyEnvelope').includes('playerEarnedScore=Math.max'),'A stale global/player response can still reduce earned gems');
|
||||
assert(functionSource('rememberStateSignatures').includes('!personalEconomyMode()'),'World-store purchase deltas can still reduce personal gems');
|
||||
|
||||
assert(functionSource('finalizeAtGate').includes('commitConnectedLineVisuals(b,pi)')&&functionSource('joinTips').includes('commitConnectedLineVisuals'),'Connected-line visuals are not scheduled after connection');
|
||||
assert(functionSource('commitConnectedLineVisuals').includes('invalidateLineGraphCaches')&&functionSource('commitConnectedLineVisuals').includes('renderBoard(b)')&&functionSource('commitConnectedLineVisuals').includes('queueLineWidthRefresh'),'Line thickness inheritance can remain stale or synchronously block pointer input after connection');
|
||||
|
||||
const preserveSource=functionSource('preserveSolvedBoardState');
|
||||
assert(preserveSource.includes('solvedSource?._summaryOnly?baseState:solvedSource')&&preserveSource.includes('preserved.solved=true')&&preserveSource.includes('delete preserved._summaryOnly'),'Solved summary recovery can still erase full route data');
|
||||
assert(functionSource('applyWorldSignal').includes('current?.solved&&!incoming.solved&&compatible')&&functionSource('applyWorldSignal').includes('preserveSolvedBoardState(current,incoming)'),'Cross-tab stale unsolved records can still overwrite a clear');
|
||||
assert(functionSource('hydrateMeta').includes('currentState?.solved&&!loadedState.solved')&&functionSource('hydrateMeta').includes('preserveSolvedBoardState(currentState,loadedState)'),'Rehydration can still restore an older unsolved state');
|
||||
assert(functionSource('evictHydratedBoardDetails').includes('summarizeBoardV2(target,currentState'),'Eviction can still cache a stale pre-clear summary');
|
||||
|
||||
const context={SCORE_VERSION:6,deepClone:value=>JSON.parse(JSON.stringify(value)),mergeSpecialProgress:(a,b)=>({crossings:[...(a?.crossings||[]),...(b?.crossings||[])]}),mergePurchases:(a=[],b=[])=>[...a,...b],normalizeState:()=>({paths:[],specialProgress:{crossings:[]}})};
|
||||
vm.createContext(context);vm.runInContext(`${preserveSource}\nthis.preserveSolvedBoardState=preserveSolvedBoardState;`,context);
|
||||
const summary={_summaryOnly:true,solved:true,expanded:true,solvedBy:'A',scoreAwarded:500,specialProgress:{crossings:[[1,1]]}},incoming={solved:false,expanded:false,paths:[{cells:[[0,0],[0,1]]}],specialProgress:{crossings:[]},scoreAwarded:0};
|
||||
const preserved=context.preserveSolvedBoardState(summary,incoming);
|
||||
assert(preserved.solved===true&&preserved.paths.length===1&&preserved.paths[0].cells.length===2&&!('_summaryOnly' in preserved),'Solved-summary repair did not retain incoming full route data');
|
||||
|
||||
console.log('v47.83 map, store, economy, line inheritance, and clear persistence regression test passed');
|
||||
53
test/v4784-pan-solve-production-smoke-test.js
Normal file
53
test/v4784-pan-solve-production-smoke-test.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
'use strict';
|
||||
const {vm,assert,app,functionSource}=require('./helpers/app-source');
|
||||
|
||||
assert(!app.includes('SPECIAL_CELL_DEBUG_ALL_LEVELS'),'Special-cell debug scheduling remains in production code');
|
||||
assert(functionSource('addSpecialCellPattern').includes('AppLogic.specialSchedule(level,'),'Special cells do not use the production level directly');
|
||||
|
||||
const solvedState={paths:[],specialProgress:{crossings:[]},solved:true,expanded:true,solvedBy:'Player',scoreAwarded:500,store:null,rev:10};
|
||||
const sanitizeContext={
|
||||
puzzleOf:()=>({}),metaState:()=>solvedState,cellSet:()=>new Set(),crossingKeys:()=>[],warpMap:()=>new Map(),
|
||||
normalizePath:value=>value,ckey:()=>'',sameCell:()=>false,pathCellsAdjacent:()=>false,nextRevision:()=>11,
|
||||
markStateDirty:()=>{throw new Error('A durable clear was marked dirty by route revalidation')},
|
||||
isSolved:()=>false,LEGACY_LOCAL_SOLVER:'Legacy'
|
||||
};
|
||||
vm.createContext(sanitizeContext);
|
||||
vm.runInContext(`${functionSource('sanitizeStateForPuzzle')}\nthis.sanitizeStateForPuzzle=sanitizeStateForPuzzle;`,sanitizeContext);
|
||||
sanitizeContext.sanitizeStateForPuzzle({id:'B0'},{quiet:true});
|
||||
assert(solvedState.solved===true&&solvedState.scoreAwarded===500&&solvedState.solvedBy==='Player','Route revalidation downgraded a durable clear');
|
||||
|
||||
let center=[0,0];
|
||||
const coverageContext={
|
||||
overviewCache:{revision:4,width:800,height:600,scale:.2,anchorX:0,anchorY:0,unit:20,overscan:100},
|
||||
minimapWorldRevision:4,cam:{scale:.2},MIN_CAMERA_SCALE:.1,getViewportRect:()=>({width:800,height:600}),
|
||||
cameraCenterInChunks:()=>center
|
||||
};
|
||||
vm.createContext(coverageContext);
|
||||
vm.runInContext(`${functionSource('overviewCacheNeedsInteractionRebuild')}\nthis.needsRefresh=overviewCacheNeedsInteractionRebuild;`,coverageContext);
|
||||
assert(!coverageContext.needsRefresh(),'A centered overview cache was treated as exhausted');
|
||||
center=[5,0];
|
||||
assert(coverageContext.needsRefresh(),'A held pan beyond overview overscan did not request a cache refresh');
|
||||
|
||||
let frameCallback=null,idleCallback=null,drawOptions=null;
|
||||
const scheduleContext={
|
||||
overviewFrame:0,overviewDirty:true,overviewLastDraw:0,overviewDelayTimer:0,overviewAllowInteractionBuild:false,overviewInteractionLastBuild:0,
|
||||
OVERVIEW_INTERACTION_REBUILD_INTERVAL:180,AUXILIARY_FRAME_INTERVAL:1000/30,overviewCanvas:{},
|
||||
interactionActive:()=>true,inWorldOverview:()=>true,
|
||||
requestAnimationFrame:callback=>{frameCallback=callback;return 1},
|
||||
requestIdleCallback:callback=>{idleCallback=callback;return 2},
|
||||
markVisualFrame:()=>{},drawWorldOverview:options=>{drawOptions=options},perfNow:()=>201
|
||||
};
|
||||
vm.createContext(scheduleContext);
|
||||
vm.runInContext(`${functionSource('scheduleWorldOverview')}\nthis.scheduleWorldOverview=scheduleWorldOverview;`,scheduleContext);
|
||||
scheduleContext.scheduleWorldOverview(false,{allowDuringInteraction:true});
|
||||
assert(typeof frameCallback==='function','An in-gesture overview refresh was not frame-scheduled');
|
||||
frameCallback(200);
|
||||
assert(typeof idleCallback==='function','An in-gesture overview refresh was not deferred to idle time');
|
||||
idleCallback();
|
||||
assert(drawOptions?.allowDuringInteraction===true&&scheduleContext.overviewInteractionLastBuild===201,'The bounded overview refresh was blocked while panning');
|
||||
|
||||
const applyCamera=functionSource('applyCamera');
|
||||
assert(applyCamera.includes('overviewCacheNeedsInteractionRebuild()')&&applyCamera.includes('allowDuringInteraction:cameraGestureActive'),'Camera painting does not replenish an exhausted overview cache during a held pan');
|
||||
assert(app.includes('CAMERA_DISPLAY_WATCHDOG_MS=34')&&functionSource('scheduleCustomCursorFrame').includes('requestAnimationFrame(paint)')&&!app.includes('CURSOR_DISPLAY_WATCHDOG_MS'),'A cursor fallback timer can still preempt the next 60 Hz display frame');
|
||||
|
||||
console.log('v47.84 pan continuity, durable clear, and production gate checks passed');
|
||||
Loading…
Add table
Add a link
Reference in a new issue