bend_puzzle/test/v4797-shared-board-input-smoke-test.js

62 lines
6.2 KiB
JavaScript
Raw Normal View History

2026-08-01 16:06:14 +09:00
'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');
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 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[0].cells,partial.paths[0].cells,'Another player did not receive unfinished board progress');
assert.equal(pulled.body.page.states.B0.solved,false);
console.log('LinkField v48.0 shared board input and progress 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})});