bend_puzzle/test/v4797-shared-board-input-smoke-test.js
33333-33333 9d70afb4cc
Some checks failed
BEND FIELD CI / release (push) Has been cancelled
BEND FIELD CI / production-bridge (push) Has been cancelled
t
2026-08-01 22:31:04 +09:00

69 lines
8.9 KiB
JavaScript

'use strict';
const {spawn}=require('child_process');
const fs=require('fs');
const os=require('os');
const path=require('path');
const assert=require('assert/strict');
const {root,starterPuzzle,read,functionSource}=require('./helpers/app-source');
const app=read('app.js'),serverSource=read('server.js'),html=read('index.html'),css=read('style.css');
assert(html.includes('class="shared-indicator"')&&html.includes('<span>共有</span>'),'Compact shared indicator is missing');
assert(!html.includes('共有 · 接続中')&&!html.includes('共有成功')&&!html.includes('共有失敗'),'Variable shared-status labels remain in the visible UI');
assert(css.includes('.shared-indicator')&&css.includes('bottom:calc(16px + var(--safe-bottom))'),'Shared indicator is not fixed above FPS');
assert(!functionSource('setCloudStatus').includes('textContent'),'Shared indicator still changes visible text');
assert(functionSource('currentCloudPending').includes('stateIds:[...cloudJournalStateIds]'),'Unfinished states are omitted from cloud pending data');
assert(!functionSource('noteCloudRow').includes("solved!==true"),'Unfinished states are still removed from the shared outbox');
assert(functionSource('requestBoardClaim').indexOf("fetchJson('/api/realtime/claim'")<functionSource('requestBoardClaim').indexOf('requestBoardClaimThroughRealtime')&&functionSource('requestBoardClaimThroughRealtime').includes("type:'claim'"),'Board claim does not use the direct shared API with realtime fallback');
assert(functionSource('markPendingClaimPointerReleased').includes('pending.released=true')&&functionSource('bindBoard').includes('finishReleased'),'A drag released while claim approval is pending is discarded');
assert(!app.includes('BroadcastChannel')&&!app.includes('syncStorageKey')&&!app.includes('queueWorldSignal'),'Retired cross-tab board synchronization remains');
assert(serverSource.includes('const state=JSON.parse(JSON.stringify(incoming));state.solved=false'),'Server still discards unfinished board paths');
assert(serverSource.includes('unfinishedBoard')&&serverSource.includes('Board claim is required'),'Unfinished state writes are not protected by the active claim');
const port=25000+Math.floor(Math.random()*5000);
const dataDir=fs.mkdtempSync(path.join(os.tmpdir(),'linkfield-v4797-'));
const child=spawn(process.execPath,[path.join(root,'server.js')],{env:{...process.env,PORT:String(port),HOST:'127.0.0.1',LINK_FIELD_TEST_DATA_ROOT:dataDir},stdio:['ignore','pipe','pipe']});
let stderr='';child.stderr.on('data',chunk=>stderr+=chunk);
const base=`http://127.0.0.1:${port}`;
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
async function request(url,options={}){const response=await fetch(base+url,options),body=await response.json();return{response,body}}
function auth(session){return{authorization:`Bearer ${session.playerId}.${session.token}`,'content-type':'application/json'}}
function boardMeta(puzzle){return{id:'B0',x:0,y:0,chunks:[[0,0]],level:1,targetLevel:1,seed:1717,axis:'MIX',sealedSides:[],puzzle,rev:1,revAuthor:'client'}}
(async()=>{
for(let index=0;index<100;index++){try{if((await request('/api/cloud/status')).response.ok)break}catch(_){}await sleep(30)}
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(),meta=boardMeta(puzzle);
let result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:0,global:{schema:31,worldGeneration:'linkfield-single-world-20260801',nextId:1},metas:[meta],states:[{id:'B0',value:{paths:[],specialProgress:{crossings:[]},solved:false}}],deleted:[]})});
assert.equal(result.response.status,200);assert.equal(result.body.revision,1);
const route=puzzle.solution[0],partial={paths:[{startGate:route.startGate,endGate:null,openGate:null,cells:route.cells.slice(0,3).map(cell=>[...cell])}],specialProgress:{crossings:[]},solved:false};
result=await request('/api/cloud/push',{method:'POST',headers:auth(bob),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:partial}],deleted:[]})});
assert.equal(result.response.status,423,'A player without the board claim changed unfinished progress');assert.equal(result.body.boardId,'B0','Claim failure did not identify the blocked board');
const bobConnected=await request('/api/realtime/connect',{method:'POST',headers:auth(bob),body:'{}'});assert.equal(bobConnected.response.status,200);const bobPresence=bobConnected.body.presenceId;
const connected=await request('/api/realtime/connect',{method:'POST',headers:auth(alice),body:'{}'});
assert.equal(connected.response.status,200);
const ready=connected.body.messages.find(message=>message.type==='ready');assert(ready?.presenceId,'Polling realtime connection did not return a presence id');
const directClaim=await request('/api/realtime/claim',{method:'POST',headers:auth(alice),body:JSON.stringify({presenceId:ready.presenceId,boardId:'B0'})});
assert.equal(directClaim.response.status,200);assert.equal(directClaim.body.ok,true,'Direct claim API failed');
const requestId='v4797-claim';
const claimEnvelope=await request('/api/realtime/send',{method:'POST',headers:auth(alice),body:JSON.stringify({presenceId:ready.presenceId,message:{type:'claim',requestId,boardId:'B0'},afterSequence:connected.body.sequence||0})});
const claimResult=claimEnvelope.body.messages.find(message=>message.type==='claim-result'&&message.requestId===requestId);
assert.equal(claimResult?.ok,true,'Claim through the cursor/reaction polling transport failed');
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:1,global:{nextId:1},metas:[],states:[{id:'B0',value:partial}],deleted:[]})});
assert.equal(result.response.status,200);assert.equal(result.body.revision,2);
const bobDraftEvents=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(bobPresence)}&after=${bobConnected.body.sequence||0}&wait=0`,{headers:auth(bob)});assert.equal(bobDraftEvents.response.status,200);assert(!bobDraftEvents.body.messages.some(message=>message.type==='world-revision'&&message.revision===2),'Every unfinished drag was announced as shared progress');
let pulled=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pulled.response.status,200);assert.deepEqual(pulled.body.page.states.B0.paths,[],'Another player received an open, single-ended draft line');
pulled=await request('/api/cloud/pull?since=0',{headers:auth(alice)});assert.deepEqual(pulled.body.page.states.B0.paths[0].cells,partial.paths[0].cells,'The active claimant could not recover their own draft line');
assert(puzzle.solution.length>1,'Starter puzzle needs two routes for shared-progress coverage');const second=puzzle.solution[1],mixed={paths:[{startGate:route.startGate,endGate:route.endGate,openGate:null,cells:route.cells.map(cell=>[...cell])},{startGate:second.startGate,endGate:null,openGate:null,cells:second.cells.slice(0,Math.max(1,Math.min(3,second.cells.length))).map(cell=>[...cell])}],specialProgress:{crossings:[]},solved:false};
result=await request('/api/cloud/push',{method:'POST',headers:auth(alice),body:JSON.stringify({baseRevision:2,global:{nextId:1},metas:[],states:[{id:'B0',value:mixed}],deleted:[]})});assert.equal(result.response.status,200);assert.equal(result.body.revision,3);
const bobRealtime=await request(`/api/realtime/poll?presenceId=${encodeURIComponent(bobPresence)}&after=${bobDraftEvents.body.sequence||0}&wait=1000`,{headers:auth(bob)});assert.equal(bobRealtime.response.status,200);const progressRevision=bobRealtime.body.messages.find(message=>message.type==='world-revision'&&message.revision===3&&message.boardIds.includes('B0'));assert(progressRevision,'A completed gate-to-gate line did not emit shared progress');assert.equal(progressRevision.page.states.B0.paths.length,1,'Realtime progress leaked an unfinished draft or omitted the completed line');assert.equal(progressRevision.page.states.B0.paths[0].endGate,route.endGate,'Realtime progress did not contain the latest completed gate-to-gate line');
pulled=await request('/api/cloud/pull?since=0',{headers:auth(bob)});assert.equal(pulled.body.page.states.B0.paths.length,1);assert.equal(pulled.body.page.states.B0.paths[0].endGate,route.endGate,'Other player did not receive the completed gate-to-gate line');
pulled=await request('/api/cloud/pull?since=0',{headers:auth(alice)});assert.equal(pulled.body.page.states.B0.paths.length,2,'The claimant lost their private open draft while sharing a completed line');
console.log('LinkField v48.03 immediate completed-line progress visibility smoke test passed');
})().catch(error=>{console.error(error);if(stderr)console.error(stderr);process.exitCode=1}).finally(()=>{child.kill('SIGTERM');fs.rmSync(dataDir,{recursive:true,force:true})});