295 lines
27 KiB
JavaScript
295 lines
27 KiB
JavaScript
'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);
|
|
vm.runInContext(`${functionSource('cellsCrossedBySegment')}\n${functionSource('liveEndpointPoint')}\n${functionSource('hexRgb')}\n${functionSource('mixHex')}\n${functionSource('pathStrokePieces')}\nthis.logic={cellsCrossedBySegment,liveEndpointPoint,pathStrokePieces};`,context);
|
|
const {cellsCrossedBySegment,liveEndpointPoint,pathStrokePieces}=context.logic;
|
|
const horizontal=cellsCrossedBySegment([36,36],[76,76],{preferredAxis:'H',validCells:new Set(['0,0','0,1','1,1'])});
|
|
assert(JSON.stringify(horizontal)==='[[0,1],[1,1]]','Exact-corner drag did not follow the horizontal valid route');
|
|
const vertical=cellsCrossedBySegment([36,36],[76,76],{preferredAxis:'H',validCells:new Set(['0,0','1,0','1,1'])});
|
|
assert(JSON.stringify(vertical)==='[[1,0],[1,1]]','Exact-corner drag ignored the only valid route');
|
|
const board={drawing:{pointerId:7,pathIndex:2,lastPoint:[120,44],renderedHandlePosition:[80,44]}},tip=liveEndpointPoint(board,2,[80,44]);
|
|
assert(tip[0]===80&&tip[1]===44,'Live endpoint followed raw pointer noise instead of the confirmed cell center');
|
|
board.drawing.renderedHandlePosition=[120,44];
|
|
assert(liveEndpointPoint(board,2,[80,44])[0]===120,'Live endpoint did not show the buffered next-cell center');
|
|
assert(liveEndpointPoint(board,1,[80,44])[0]===80,'Live endpoint affected a non-active line');
|
|
const pieces=pathStrokePieces([[[0,0],[30,0],[30,40],[50,40]]],'#000000','#ffffff');
|
|
assert(pieces.length===1&&pieces[0].points.length===4,'A continuous bent route was split into excess SVG gradient nodes');
|
|
assert(Math.abs(pieces[0].startProgress)<1e-9&&Math.abs(pieces.at(-1).endProgress-1)<1e-9,'Route color blend does not span the full path');
|
|
const splitPieces=pathStrokePieces([[[0,0],[30,0]],[[70,0],[70,40],[90,40]]],'#000000','#ffffff');
|
|
assert(splitPieces.length===2,'A warp-separated route did not retain one gradient node per visible segment');
|
|
assert(splitPieces[0].endColor===splitPieces[1].startColor,'Route color blend has a seam between visible segments');
|
|
assert(splitPieces[0].endProgress<splitPieces[1].endProgress,'Route color blend progress is not monotonic');
|
|
console.log('Pointer traversal, live endpoint, and path-length color blending test passed');
|
|
const gestureContext={
|
|
CELL:40,POINTER_SNAP_THRESHOLD:18,POINTER_DOMINANT_RATIO:1.25,DRAG_EDGE_MARGIN:76,DRAG_EDGE_MAX_SPEED:.58,
|
|
ckey:(r,c)=>`${r},${c}`,activePath:b=>b.path,boardCellCenter:([r,c])=>[16+(c+.5)*40,16+(r+.5)*40],pathTailAxis:()=> 'H',
|
|
getViewportRect:()=>({left:0,top:0,right:1000,bottom:800,width:1000,height:800})
|
|
};
|
|
vm.createContext(gestureContext);
|
|
vm.runInContext(`${functionSource('directionFromDelta')}\n${functionSource('gateHitBox')}\n${functionSource('edgePanVelocity')}\nthis.gesture={directionFromDelta,gateHitBox,edgePanVelocity};`,gestureContext);
|
|
assert(gestureContext.gesture.directionFromDelta(17,0)===null&&gestureContext.gesture.directionFromDelta(19,0)==='E','Single snap threshold is not applied directly');
|
|
assert(gestureContext.gesture.directionFromDelta(19,18,'H')==='E'&&gestureContext.gesture.directionFromDelta(19,18,'V')==='S'&&gestureContext.gesture.directionFromDelta(0,-19)==='N','Diagonal drag does not select a stable orthogonal axis');
|
|
const eastHit=gestureContext.gesture.gateHitBox([200,120],'E'),westHit=gestureContext.gesture.gateHitBox([200,120],'W');
|
|
assert(eastHit.x<200&&eastHit.x+eastHit.width===200&&westHit.x===200&&westHit.x+westHit.width>200,'Gate hit boxes are not confined to the owning board');
|
|
assert(eastHit.width<=gestureContext.CELL*.5&&westHit.width<=gestureContext.CELL*.5,'Gate hit boxes extend too far into the puzzle');
|
|
const centerVelocity=gestureContext.gesture.edgePanVelocity(500,400),rightVelocity=gestureContext.gesture.edgePanVelocity(998,400);
|
|
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');
|
|
|
|
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={};
|
|
sampleContext.pointer.appendBoardPointerSamples(sampleBoard,{pointerId:12,pointerType:'pen',getCoalescedEvents:()=>cornerSamples});
|
|
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');
|
|
|
|
assert(!functionSource('renderDragFrame').includes('motion')&&!functionSource('extendPointerTo').includes('Motion'),'Line extension still performs animation bookkeeping');
|
|
console.log('Bounded ordered-sample pickup gesture and immediate line rendering test passed');
|
|
|
|
const pickupState={paths:[{startGate:0,endGate:null,cells:[[0,0]]},{startGate:1,endGate:null,cells:[[0,1]]}]},pickupContext={
|
|
manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]),ckey:(r,c)=>`${r},${c}`,cellSet:p=>p.validSet,metaState:()=>pickupState
|
|
};
|
|
vm.createContext(pickupContext);
|
|
vm.runInContext(`${functionSource('openTipMergePlan')}\nthis.openTipMergePlan=openTipMergePlan;`,pickupContext);
|
|
const pickupBoard={id:'B0',p:{validSet:new Set(['0,0','0,1'])},drawing:{pathIndex:0}};
|
|
assert(pickupContext.openTipMergePlan(pickupBoard,0,1)===null,'Adjacent pickups connected without occupying the same cell');
|
|
assert(pickupContext.openTipMergePlan(pickupBoard,0,1,'end',true)?.merged.cells.length===2,'A knob explicitly moved into the other endpoint cell did not connect');
|
|
pickupContext.metaState=()=>({paths:[{startGate:0,endGate:null,cells:[[0,0]]},{startGate:1,endGate:null,cells:[[0,0]]}]});
|
|
const sameCellPickupBoard={id:'B0',p:{validSet:new Set(['0,0'])},drawing:{pathIndex:0}},sameCellMerge=pickupContext.openTipMergePlan(sameCellPickupBoard,0,1);
|
|
assert(sameCellMerge&&sameCellMerge.merged.cells.length===1,'Two pickups in the same cell did not merge without duplicating the cell');
|
|
|
|
const overlapState={paths:[
|
|
{startGate:0,endGate:null,cells:[[0,0],[1,0],[1,1]]},
|
|
{startGate:1,endGate:null,cells:[[0,2],[1,2],[1,1],[2,1]]}
|
|
]};
|
|
pickupContext.metaState=()=>overlapState;
|
|
const overlapBoard={id:'B0',p:{validSet:new Set(['0,0','1,0','1,1','0,2','1,2','2,1'])},drawing:{pathIndex:0}};
|
|
assert(pickupContext.openTipMergePlan(overlapBoard,0,1)===null,'Overlapping pickups were allowed to create a self-duplicated path');
|
|
|
|
const mergeState={paths:[
|
|
{startGate:0,endGate:null,cells:[[0,0]]},
|
|
{startGate:1,endGate:null,cells:[[0,0]]}
|
|
]};
|
|
let cancelledMergeFrames=0,releasedMergePointers=0;
|
|
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++,commitConnectedLineVisuals:()=>{},playSound:()=>{},queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
|
|
};
|
|
vm.createContext(mergeContext);
|
|
vm.runInContext(`${functionSource('openTipMergePlan')}\n${functionSource('joinTips')}\nthis.joinTips=joinTips;`,mergeContext);
|
|
const mergeBoard={id:'B0',p:{validSet:new Set(['0,0'])},drawing:{pathIndex:0,pointerId:17},svg:{},joiningPaths:false};
|
|
assert(mergeContext.joinTips(mergeBoard,0,1),'Same-cell pickup merge was rejected');
|
|
assert(mergeState.paths.length===1&&mergeState.paths[0].cells.length===1,'Pickup merge duplicated the shared cell');
|
|
assert(mergeBoard.drawing===null&&cancelledMergeFrames===1&&releasedMergePointers===1,'Pickup merge did not terminate its pending frame and pointer capture exactly once');
|
|
assert(!app.includes('function pickupJoinStepsAtPoint')&&!app.includes('function gateConnectionSteps'),'Removed adjacent pickup/gate auto-bridging code is still present');
|
|
|
|
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},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')}\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.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();
|
|
assert(cameraApplies===1,'Camera watchdog duplicated a completed animation-frame commit');
|
|
console.log('Pickup capture and frame-coalesced panning test passed');
|
|
|
|
const blankTarget={closest:()=>null};
|
|
const card=(solved=false)=>({dataset:{id:'B0'},classList:{contains:name=>name==='solved'&&solved}});
|
|
const targetForCard=board=>({closest:selector=>selector==='.board-card'?board:null});
|
|
const panContext={pendingFieldItemPlacement:null,data:{metas:{B0:{id:'B0'}}},metaState:()=>({solved:false})};
|
|
vm.createContext(panContext);
|
|
vm.runInContext(`${functionSource('leftFieldPanAllowed')}\nthis.leftFieldPanAllowed=leftFieldPanAllowed;`,panContext);
|
|
assert(panContext.leftFieldPanAllowed({button:0,target:blankTarget}),'Blank undiscovered field did not allow left-drag panning');
|
|
assert(!panContext.leftFieldPanAllowed({button:0,target:targetForCard(card(false))}),'Unsolved puzzle area stole left-drag for field panning');
|
|
assert(panContext.leftFieldPanAllowed({button:0,target:targetForCard(card(true))}),'Solved puzzle area did not allow left-drag panning');
|
|
assert(!panContext.leftFieldPanAllowed({button:2,target:blankTarget}),'Left-pan eligibility accepted a non-left button');
|
|
console.log('Solved/undiscovered left-drag pan eligibility test passed');
|
|
|
|
const overviewContext={
|
|
metaState:id=>({solved:id==='S'}),currentLineGraphCaches:()=>({components:new Map(),geometries:new Map()}),
|
|
collectConnectedLineComponent:()=>[],minimapGeometryForComponent:()=>({segments:[]}),MINIMAP_LONG_LINE:4
|
|
};
|
|
vm.createContext(overviewContext);
|
|
vm.runInContext(`${functionSource('drawMapBoardCells')}\nthis.drawMapBoardCells=drawMapBoardCells;`,overviewContext);
|
|
let fills=0,strokes=0;const mapContext={beginPath:()=>{},rect:()=>{},fill:()=>{fills++},stroke:()=>{strokes++},set fillStyle(_value){}};
|
|
overviewContext.drawMapBoardCells(mapContext,[{id:'S',x:0,y:0,chunks:[[0,0]]},{id:'U',x:1,y:0,chunks:[[0,0]]}],x=>x,y=>y,1);
|
|
assert(fills===2&&strokes===0,'Minimap/overview board renderer still draws board outlines');
|
|
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!functionSource('rebuildWorldOverviewCache').includes('storeCellForMeta'),'Far overview does not exactly reuse minimap board/line rendering');
|
|
console.log('Borderless shared minimap/overview renderer test passed');
|
|
|
|
let resetRenderCount=0,resetChangeCount=0,resetSyncCount=0;
|
|
const resetState={paths:[{cells:[[0,0],[0,1]]}],specialProgress:{crossings:[[0,0]]},solved:false};
|
|
const resetBoard={id:'B0',drawing:{pointerId:9},armedGate:1,solvedPathsRendered:true,svg:{hasPointerCapture:()=>true,releasePointerCapture:()=>{}}};
|
|
const resetContext={
|
|
rendered:new Map(),activeBoard:'B0',metaState:()=>resetState,cancelBoardDragFrame:()=>{},clearTipMotion:()=>{},
|
|
applyBoardCommand:(board,mutate,options)=>{mutate(resetState);if(options.persist)resetChangeCount++},
|
|
renderBoardNow:()=>resetRenderCount++,updateSelectedProgress:()=>{},queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{},
|
|
syncBoundaryConnections:()=>resetSyncCount++,toast:()=>{}
|
|
};
|
|
vm.createContext(resetContext);
|
|
vm.runInContext(`${functionSource('resetSelectedBoard')}\nthis.resetSelectedBoard=resetSelectedBoard;`,resetContext);
|
|
resetContext.resetSelectedBoard(resetBoard);
|
|
assert(resetState.paths.length===0&&resetState.specialProgress.crossings.length===0,'First reset click did not clear the puzzle');
|
|
assert(resetRenderCount===1&&resetChangeCount===1&&resetSyncCount===0,'Reset was not immediately rendered and persisted exactly once');
|
|
console.log('First-click reset test passed');
|
|
|
|
const shopItems=[...Array.from({length:12},(_,index)=>({id:`O${index+1}`})),...Array.from({length:6},(_,index)=>({id:`C${index+1}`,cursorStyle:`face-${index+1}`}))];
|
|
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('renderStorePanel').includes("category.compact?' store-compact'"),'Shop rendering bypasses the fixed 12+6 item inventory or compact cosmetic layout');
|
|
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}),puzzleOf:meta=>meta.puzzle
|
|
};
|
|
vm.createContext(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(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');
|
|
|
|
const orphanState={paths:[{startGate:0,endGate:null,cells:[[0,0]]}]},orphanContext={
|
|
metaState:()=>orphanState,applyBoardCommand:(_board,mutate)=>{mutate();return true}
|
|
};
|
|
vm.createContext(orphanContext);
|
|
vm.runInContext(`${functionSource('discardUnmovedCreatedPath')}\nthis.discardUnmovedCreatedPath=discardUnmovedCreatedPath;`,orphanContext);
|
|
const orphanBoard={id:'B0',drawing:{pathIndex:0,createdPath:true},armedGate:0};
|
|
assert(orphanContext.discardUnmovedCreatedPath(orphanBoard)&&orphanState.paths.length===0&&orphanBoard.drawing===null,'Cancelled one-cell drag leaves an orphan pickup');
|
|
|
|
const numberNode={
|
|
classList:{on:false,toggle(_name,value){this.on=value}},
|
|
style:{value:null,setProperty(_name,value){this.value=value},removeProperty(){this.value=null}}
|
|
},numberContext={
|
|
ckey:(r,c)=>`${r},${c}`,metaState:()=>({paths:[{cells:[[0,0]]}]}),occupiedMap:()=>new Map([['0,0',0]]),pathColorAtCell:()=> '#72e38f'
|
|
};
|
|
vm.createContext(numberContext);
|
|
vm.runInContext(`${functionSource('refreshDragNumberColors')}\nthis.refreshDragNumberColors=refreshDragNumberColors;`,numberContext);
|
|
numberContext.refreshDragNumberColors({id:'B0',p:{},drawing:{pathIndex:0,segmentOccupancy:new Map([['0,0',0]])},numberNodes:new Map([['0,0',numberNode]])});
|
|
assert(numberNode.classList.on&&numberNode.style.value==='#72e38f'&&functionSource('renderDragFrame').includes('refreshDragNumberColors(b)'),'Number color does not update during pickup dragging');
|
|
|
|
const detachedState={paths:[{startGate:0,endGate:null,openGate:null,cells:[[0,0],[0,1],[0,2]],colorIndex:2,startColorIndex:2,endColorIndex:null}]};
|
|
const detachedContext={
|
|
metaState:()=>detachedState,applyBoardCommand:(_board,mutate)=>{mutate();return true},
|
|
drawingFromGate:(_board,pathIndex,gateIndex,pointerId)=>({pathIndex,gateIndex,pointerId})
|
|
};
|
|
vm.createContext(detachedContext);
|
|
vm.runInContext(`${functionSource('pathUsesGate')}\n${functionSource('detachPathFromStartGate')}\n${functionSource('reverseDetachedPath')}\nthis.detached={pathUsesGate,detachPathFromStartGate,reverseDetachedPath};`,detachedContext);
|
|
const detachedBoard={id:'B0',drawing:null,armedGate:null};
|
|
assert(detachedContext.detached.detachPathFromStartGate(detachedBoard,0,0,19),'Dragging the gate of an open line did not detach its anchored end');
|
|
const detachedPath=detachedState.paths[0];
|
|
assert(detachedPath.detachedStart&&JSON.stringify(detachedPath.cells)==='[[0,2],[0,1],[0,0]]','Detached line does not retain two oriented edge pickups');
|
|
assert(detachedContext.detached.pathUsesGate(detachedPath,0)&&detachedBoard.drawing.pointerId===19,'New gate-side pickup is not associated with the active drag');
|
|
assert(!functionSource('renderBoardNow').includes("whitePickupEnd")&&functionSource('renderBoardNow').includes("'data-endpoint-side':'start'"),'Two-ended line does not render both colored pickup handles');
|
|
const normalizeDetachedContext={isPlainObject:value=>value&&typeof value==='object'&&!Array.isArray(value),LINE_COLORS:Array(10).fill('#000'),LINE_EFFECT_IDS:new Set(['glow','neon'])};
|
|
vm.createContext(normalizeDetachedContext);
|
|
vm.runInContext(`${functionSource('normalizePath')}\nthis.normalizePath=normalizePath;`,normalizeDetachedContext);
|
|
const normalizedDetached=normalizeDetachedContext.normalizePath(detachedPath);
|
|
assert(normalizedDetached.detachedStart&&!('whitePickupEnd' in normalizedDetached),'Two-ended pickup state is lost or retains the obsolete white pickup marker during persistence normalization');
|
|
|
|
pickupContext.metaState=()=>({paths:[
|
|
{startGate:0,endGate:null,detachedStart:true,cells:[[0,0],[0,1]],colorIndex:2,startColorIndex:2,endColorIndex:2},
|
|
{startGate:1,endGate:null,cells:[[0,3],[0,2],[0,1]],colorIndex:4,startColorIndex:4,endColorIndex:null}
|
|
]});
|
|
const detachedMergeBoard={id:'B0',p:{validSet:new Set(['0,0','0,1','0,2','0,3'])},drawing:{pathIndex:0}},
|
|
detachedMerge=pickupContext.openTipMergePlan(detachedMergeBoard,0,1);
|
|
assert(detachedMerge&&!detachedMerge.merged.detachedStart&&detachedMerge.merged.startGate===1&&detachedMerge.merged.endGate==null&&JSON.stringify(detachedMerge.merged.cells)==='[[0,3],[0,2],[0,1],[0,0]]','An isolated line could not merge into an anchored pickup');
|
|
pickupContext.metaState=()=>({paths:[
|
|
{startGate:0,endGate:null,cells:[[0,0]],colorIndex:1,startColorIndex:1},
|
|
{startGate:1,endGate:null,detachedStart:true,cells:[[0,0],[0,1],[0,2]],colorIndex:3,startColorIndex:3,endColorIndex:3}
|
|
]});
|
|
const detachedStartMerge=pickupContext.openTipMergePlan(detachedMergeBoard,0,1,'start');
|
|
assert(detachedStartMerge&&JSON.stringify(detachedStartMerge.merged.cells)==='[[0,0],[0,1],[0,2]]','The opposite endpoint of an isolated line could not be joined');
|
|
|
|
const graphData={metas:{A:{id:'A',puzzle:{}},B:{id:'B',puzzle:{}}},states:{
|
|
A:{paths:[{startGate:0,endGate:null,detachedStart:true,cells:[[0,0],[0,1]]}]},
|
|
B:{paths:[{startGate:0,endGate:null,cells:[[0,0],[1,0],[2,0]]}]}
|
|
}};
|
|
const graphEnvironment={data:graphData,metaState:id=>graphData.states[id],matchingNeighborGate:meta=>meta.id==='A'?{meta:graphData.metas.B,gateIndex:0}:{meta:graphData.metas.A,gateIndex:0}};
|
|
const detachedComponent=AppLogic.collectConnectedLineComponent(graphData.metas.A,0,new Map(),graphEnvironment);
|
|
assert(detachedComponent.members.length===1&&detachedComponent.length===2,'Detached pickup still inherits thickness through its former gate');
|
|
|
|
const reconnectState={paths:[{startGate:0,endGate:null,openGate:0,detachedStart:true,cells:[[0,1],[0,0]],colorIndex:2,startColorIndex:2,endColorIndex:2}]},gateEffects=[];
|
|
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:()=>{},
|
|
commitConnectedLineVisuals:()=>{},cancelBoardDragFrame:()=>{},clearDragRender:()=>{},hidePickupHandleOverlay:()=>{},safeRelease:()=>{}
|
|
};
|
|
vm.createContext(reconnectContext);
|
|
vm.runInContext(`${functionSource('finalizeAtGate')}\nthis.finalizeAtGate=finalizeAtGate;`,reconnectContext);
|
|
const reconnectBoard={id:'B0',p:{g:[[0,0,'W']]},drawing:{pathIndex:0},armedGate:0};
|
|
assert(reconnectContext.finalizeAtGate(reconnectBoard,0),'Detached pickup could not reconnect to its original gate');
|
|
assert(!reconnectState.paths[0].detachedStart&&reconnectState.paths[0].startGate===0&&gateEffects[0]==='#0f0','Reconnected two-ended line did not collapse to one anchored line with its line-color effect');
|
|
console.log('Shop access/order, orphan cleanup, live number color, and dual-pickup tests passed');
|
|
|
|
const rewardContext={MAX_SCORE:1000000000,anomalyScoreMultiplier:()=>1};
|
|
vm.createContext(rewardContext);
|
|
vm.runInContext(`${functionSource('scoreFromThickness')}\nthis.scoreFromThickness=scoreFromThickness;`,rewardContext);
|
|
const smallEasy=rewardContext.scoreFromThickness({level:2,chunks:[[0,0]]},20),
|
|
smallHard=rewardContext.scoreFromThickness({level:10,chunks:[[0,0]]},20),
|
|
largeHard=rewardContext.scoreFromThickness({level:10,chunks:Array.from({length:8},(_,index)=>[index,0])},20);
|
|
assert(smallHard>smallEasy*8&&largeHard>smallHard*2.5,'Hard and large puzzle rewards do not scale strongly enough with expected solve time');
|
|
|
|
const gateArmingState={paths:[{startGate:0,endGate:null,openGate:0,cells:[[0,0]]}]},gateArmingContext={
|
|
metaState:()=>gateArmingState,sameCell:(a,b)=>a&&b&&a[0]===b[0]&&a[1]===b[1]
|
|
};
|
|
vm.createContext(gateArmingContext);
|
|
vm.runInContext(`${functionSource('updateGateSnapArming')}\nthis.updateGateSnapArming=updateGateSnapArming;`,gateArmingContext);
|
|
const gateArmingBoard={id:'B0'},gateDrawing={pathIndex:0,originGate:0,originGateCell:[0,0],gateSnapArmed:false,leftOriginGateCell:false};
|
|
gateArmingContext.updateGateSnapArming(gateArmingBoard,gateDrawing);
|
|
assert(!gateDrawing.gateSnapArmed&&!gateDrawing.leftOriginGateCell,'A connected gate rearmed before the line endpoint left its gate cell');
|
|
gateArmingState.paths[0].cells.push([0,1]);gateArmingContext.updateGateSnapArming(gateArmingBoard,gateDrawing);
|
|
assert(gateDrawing.gateSnapArmed&&gateDrawing.leftOriginGateCell,'Dragging a connected gate endpoint out of its cell did not unlock movement');
|
|
assert(functionSource('extendPointerTo').includes("snappedGate===drawing.originGate&&!drawing.leftOriginGateCell"),'The origin gate can recapture a connected endpoint before it moves away');
|
|
|
|
const disappearingState={paths:[{startGate:0,endGate:null,detachedStart:true,cells:[[0,0],[0,1]],colorIndex:1,startColorIndex:1,endColorIndex:1}]};
|
|
let disappearingReleased=0,disappearingPersisted=0;
|
|
const disappearingContext={
|
|
CELL:40,metaState:()=>disappearingState,boardCellCenter:([r,c])=>[20+c*40,20+r*40],pointerLocalRadius:(_board,pixels)=>pixels,
|
|
cancelBoardDragFrame:()=>{},clearTipMotion:()=>{},applyBoardCommand:(_board,mutate,options)=>{const result=mutate();if(result===false)return false;if(options?.persist)disappearingPersisted++;return true},
|
|
safeRelease:()=>disappearingReleased++,playSound:()=>{},queueMicrotask:callback=>callback(),reconcileDrawingPresentation:()=>{}
|
|
};
|
|
vm.createContext(disappearingContext);
|
|
vm.runInContext(`${functionSource('removeDetachedPathAtOwnEndpoint')}\nthis.removeDetachedPathAtOwnEndpoint=removeDetachedPathAtOwnEndpoint;`,disappearingContext);
|
|
const disappearingBoard={id:'B0',drawing:{pathIndex:0,pointerId:23},armedGate:null,svg:{}};
|
|
assert(!disappearingContext.removeDetachedPathAtOwnEndpoint(disappearingBoard,[60,20]),'A two-ended line vanished before its handles overlapped');
|
|
assert(disappearingContext.removeDetachedPathAtOwnEndpoint(disappearingBoard,[20,20]),'Overlapping both handles did not remove the two-ended line');
|
|
assert(disappearingState.paths.length===0&&disappearingBoard.drawing===null&&disappearingReleased===1&&disappearingPersisted===1,'Two-ended line removal did not cleanly persist and release pointer capture');
|
|
console.log('Connected-gate dragging and two-handle line disappearance tests passed');
|
|
|
|
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(!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');
|