'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('共有'),'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'")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})});