bend_puzzle/test/interaction-smoke-test.js
2026-08-01 16:06:14 +09:00

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&&centerVelocity[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');