feat: integrate platform room entry, UI migration and erqiwang documentation

This commit is contained in:
2026-09-07 21:01:08 +08:00
parent 9c4510697c
commit 30a1b4114c
259 changed files with 84995 additions and 30201 deletions
@@ -0,0 +1,36 @@
/** Test extracted panels with local clipboard, image and command fixtures only. */
export async function verifyFeedbackSplitPreview(e){
const checks=[],assert=(v,m)=>{if(!v)throw Error(m);checks.push(m);},sleep=ms=>new Promise(r=>setTimeout(r,ms));
const load=id=>new Promise((r,j)=>e.assetManager.loadAny(id,(err,a)=>err?j(err):r(a)));
const refs=(await load('1c10c2ee-adc2-496f-9005-d3e0e6ebbb56')).scene.getComponentInChildren('LobbyAssets');
assert(refs.redeemCode.data.name==='RedeemCode'&&refs.redeemCode.data.children.length===1&&refs.redeemCode.data.children[0].name==='group-74','redeem prefab contains only group-74');
assert(refs.qrCode.data.name==='QrCode'&&refs.qrCode.data.children.length===1&&refs.qrCode.data.children[0].name==='group-42','QR prefab contains only group-42');
assert(!('notices' in refs),'obsolete shared asset field is removed');
const C=Array.from(System.entries()).map(([,m])=>m).find(m=>m.LobbyPanel).LobbyPanel,panel=Object.create(C.prototype);
const host=new e.Node('FeedbackSplitTest');host.parent=e.director.getScene();
const redeemed=[],shares=[],tips=[],images=[];const loadRemote=e.assetManager.loadRemote;
panel.assets=refs;panel.model={canAct:true};panel.modal=null;panel.modalReleases=[];panel.disposed=false;
panel.extras={paste:async()=>'00Ab-12',redeem:code=>redeemed.push(code)};panel.nativeCall=(name,data)=>shares.push({name,data});panel.tip=message=>tips.push(message);
let qrEnabled=true;
panel.config={identity:{gameid:'test',agentid:'test',channelid:'test',marketid:'test'},remoteConfig:{getValue:key=>({WechatPublicNo_ewm:qrEnabled?'https://example.invalid/fixture.png':'',sharename:'测试',shareDesc:'说明'})[key]}};
panel.close=()=>{for(const off of panel.modalReleases.splice(0))off();panel.modal?.destroy();panel.modal=null;};
panel.open=(asset,kind)=>{panel.close();panel.modal=e.instantiate(asset);panel.modal.parent=host;panel.modalKind=kind;return panel.modal;};
e.assetManager.loadRemote=(url,options,callback)=>images.push({url,callback});
try{
panel.openCdKey();let g=panel.modal.getChildByName('group-74'),edit=g.getChildByName('CardInput').getComponent(e.EditBox);
assert(edit.string==='','redeem input starts empty');
g.getChildByName('Spirit3266').emit(e.Button.EventType.CLICK);assert(redeemed.length===0&&panel.modal!==null,'empty redeem is ignored');
g.getChildByName('Spirit3265').emit(e.Button.EventType.CLICK);await sleep(20);assert(edit.string==='00Ab-12','paste preserves case and leading zeroes');
g.getChildByName('Spirit3266').emit(e.Button.EventType.CLICK);assert(redeemed[0]==='00Ab-12'&&panel.modal===null,'redeem command receives exact input and closes');
panel.openCdKey();g=panel.modal.getChildByName('group-74');assert(g.getChildByName('CardInput').getComponent(e.EditBox).string==='','redeem reopening clears input');
g.getChildByName('反馈遮罩').emit(e.Button.EventType.CLICK);assert(panel.modal===null,'redeem shade closes');
qrEnabled=false;panel.openQrCode();assert(panel.modal===null&&images.length===0,'missing QR config retains no-op behavior');
qrEnabled=true;panel.openQrCode();g=panel.modal.getChildByName('group-42');assert(images[0].url==='https://example.invalid/fixture.png','QR request uses configured URL');
const bitmap=document.createElement('canvas');bitmap.width=bitmap.height=2;bitmap.getContext('2d').fillRect(0,0,2,2);const image=new e.ImageAsset(bitmap);
images[0].callback(null,image);const sprite=g.getChildByName('Spirit343').getComponent(e.Sprite);assert(sprite.spriteFrame?.texture.image===image,'QR image callback populates sprite');
g.getChildByName('Spirit345').emit(e.Button.EventType.CLICK);assert(shares[0].name==='friendsSharetypeUrlToptitleDescript'&&shares[0].data.webpageUrl===images[0].url,'QR share retains existing bridge payload');
g.getChildByName('二维码遮罩').emit(e.Button.EventType.CLICK);assert(panel.modal===null&&sprite.spriteFrame===null,'QR close releases image frame');
panel.openQrCode();panel.close();images[1].callback(null,image);assert(panel.modal===null&&tips.length===0,'late image callback cannot reopen a closed panel');image.destroy();
return {checks};
}finally{panel.close();host.destroy();e.assetManager.loadRemote=loadRemote;}
}
@@ -0,0 +1,64 @@
/** Run through Cocos MCP in an isolated Game View. Records packets; never binds a real account. */
export async function verifyInvitePreview(){
const e=await System.import('cc'),modules=Array.from(System.entries()).map(([,m])=>m);
const exported=name=>{const m=modules.find(m=>m[name]);if(!m)throw Error(`Missing ${name}`);return m[name];};
const source=e.director.getScene().getChildByName('PlatformServices').getComponent('PlatformStartup');
const config=source.config,assets=source.getComponent('LobbyAssets');
source.unsubscribe?.();source.runtime?.stop();source.panel?.dispose();source.panel=null;
await new Promise((resolve,reject)=>e.director.loadScene('PlatformLobby',err=>err?reject(err):resolve()));
const sent=[],tips=[],checks=[];let receive,panel,pasteResolve;
const assert=(ok,message)=>{if(!ok)throw Error(message);checks.push(message);};
const runtime=new (exported('PlatformRuntime'))({
gameEntry:exported('createLoginStageGameEntry')(config.identity.gameid,()=>{}),resolveRuntimeConfig:async()=>config,
createWireClient:()=>({subscribe:cb=>{receive=cb;return ()=>{};},start:()=>{},stop:()=>{},send:packet=>sent.push(packet),switchServer:()=>{},reconnectCurrent:()=>{}}),
scene:{showLoading:()=>{},showLogin:()=>{},showLobby:()=>{},showRoom:()=>{},showReconnect:()=>{},showKicked:()=>{},showServerDenial:()=>{},showFatal:err=>{throw err;},
showTips:(msg,time,kind)=>{if(kind==='show_message')panel?.cancelInviteRequest();tips.push(msg);}},
loadResources:async()=>{},waitForMinimumDisplay:async()=>{},getLoginDeviceSnapshot:()=>{throw Error('Inbound fixture only');},
});
await runtime.start();receive({type:'open',server:config.servers[0]});
const packet=(rpc,data)=>receive({type:'message',message:{route:'agent',rpc,data}});
const login={state:0,playerid:7,nickname:'邀请码接线验证',avatar:'',sex:0,bean:0,roomcard:3,taskstate:0,score:0,sign:null,ip:'127.0.0.1',openid:'invite-openid',unionid:'invite-unionid',invitecode:null};
packet('player_login',login);
const canvas=e.director.getScene().getChildByName('Canvas'),popup=new e.Node('InviteTestPopup');popup.parent=canvas;popup.layer=canvas.layer;popup.addComponent(e.UITransform).setContentSize(1600,720);
const remote=exported('createRemoteConfig')({gameNoticeShow:-1,agentmode:2},config.identity);
panel=new (exported('LobbyPanel'))(canvas,popup,assets,config,exported('resolveLobbyPolicy')(remote),runtime.state,msg=>tips.push(msg),()=>{},()=>null,()=>{},
exported('resolveLobbyMenuConfig')(remote),{versionState:0,launchMode:0},()=>{},()=>{},()=>{},undefined,
{query:()=>runtime.getPlayerInvitecode(),bind:code=>runtime.bindInvitecode(code),paste:()=>new Promise(resolve=>{pasteResolve=resolve;})});
popup.setSiblingIndex(canvas.children.length-1);
const off=runtime.subscribeState(state=>panel.render(state),err=>{throw err;});
const click=()=>{const node=panel.menuNodes.get(235);if(!node.parent.active)panel.menuNodes.get(3120).emit(e.Button.EventType.CLICK);assert(node.activeInHierarchy,'invite entry is visible');node.emit(e.Button.EventType.CLICK);};
const button=id=>panel.modal.getChildByPath(`group-80/Spirit${id}`).emit(e.Button.EventType.CLICK);
click();click();assert(sent.length===1&&sent[0].rpc==='get_player_invitecode','query suppresses duplicate clicks');
assert(JSON.stringify(sent[0].data)===JSON.stringify({agentid:config.identity.agentid,playerid:7,unionid:'invite-unionid',openid:'invite-openid'}),'query preserves exact identity fields');
assert(panel.modal===null,'query waits for response');
packet('get_player_invitecode',{invitecode:''});assert(panel.modalKind==='invite','query opens authored Bind prefab');
let edit=panel.modal.getChildByPath('group-80/Spirit365/InputEditBox').getComponent(e.EditBox);
assert(edit.textLabel&&edit.placeholderLabel&&edit.maxLength===-1,'native input has persisted label references and no six-digit limit');
for(const l of [edit.textLabel,edit.placeholderLabel]){const anchor=l.node.getComponent(e.UITransform).anchorPoint;assert(anchor.x===0&&anchor.y===1,'EditBox labels use native top-left anchors');}
edit.string='00x1234567';edit.node.emit(e.EditBox.EventType.TEXT_CHANGED);assert(edit.string==='001234567','input removes non-digits while retaining leading zeros');
button(366);pasteResolve('00123');await Promise.resolve();await Promise.resolve();assert(edit.string==='00123','paste callback updates input');
const submit=panel.modal.getChildByPath('group-80/Spirit367');submit.emit(e.Button.EventType.CLICK);submit.emit(e.Button.EventType.CLICK);
assert(panel.modal===null&&sent.length===2,'submit closes dialog and sends once');
assert(JSON.stringify(sent[1].data)===JSON.stringify({agentid:config.identity.agentid,playerid:7,invitecode:'00123'}),'binding preserves exact fields and leading zeros');
packet('binding_invitecode',{state:'1',error:'邀请码无效'});assert(tips.includes('邀请码无效')&&runtime.state.players.entities[7].invitecode==='','string failure state displays server text without changing code');
click();packet('get_player_invitecode',{invitecode:''});assert(panel.modalKind==='invite','unchanged query result reopens dialog');
edit=panel.modal.getChildByPath('group-80/Spirit365/InputEditBox').getComponent(e.EditBox);edit.string='00123';button(367);
packet('binding_invitecode',{state:'0',invitecode:'00123',error:'绑定成功'});assert(runtime.state.players.entities[7].invitecode==='00123'&&tips.includes('绑定成功'),'string success state updates canonical player and shows server text');
click();packet('get_player_invitecode',{invitecode:'00123'});
assert([365,366,367].every(id=>!panel.modal.getChildByPath(`group-80/Spirit${id}`).active),'bound state hides input paste and submit');
assert(panel.modal.getChildByPath('group-80/Spirit368').getComponent(e.Label).string==='您已绑定邀请码,所绑定邀请码为:00123','bound state shows exact code');
await new Promise(resolve=>e.director.once(e.Director.EVENT_AFTER_DRAW,resolve));
const messageBox=panel.modal.getChildByPath('group-80/Spirit368').getComponent(e.UITransform).getBoundingBoxToWorld();
const backgroundBox=panel.modal.getChildByPath('group-80/Spirit363').getComponent(e.UITransform).getBoundingBoxToWorld();
assert(messageBox.x>=backgroundBox.x&&messageBox.xMax<=backgroundBox.xMax,'bound message stays inside authored background');
button(362);assert(panel.modal===null,'mask closes dialog');
click();packet('show_message',{msg:'暂不可用',time:0});assert(panel.invitePending===null,'generic denial releases pending request');
click();packet('get_player_invitecode',{invitecode:''});button(366);button(362);pasteResolve('late');await Promise.resolve();assert(panel.modal===null,'late paste cannot reopen a closed dialog');
click();packet('get_player_invitecode',{invitecode:''});
edit=panel.modal.getChildByPath('group-80/Spirit365/InputEditBox').getComponent(e.EditBox);edit.string='00123';
panel.requestInvite('query',()=>runtime.getPlayerInvitecode());receive({type:'slow'});assert(panel.invitePending===null,'disconnect releases pending request');
const count=sent.length;panel.menuNodes.get(235).emit(e.Button.EventType.CLICK);assert(sent.length===count,'disconnect blocks sending');
await new Promise(resolve=>e.director.once(e.Director.EVENT_AFTER_DRAW,resolve));
globalThis.__inviteVerification={panel,runtime,checks,close:()=>{off();panel.dispose();runtime.stop();popup.destroy();}};
return {checks,packets:sent.map(({rpc,data})=>({rpc,fields:Object.keys(data)}))};
}
@@ -0,0 +1,52 @@
/** Cocos Game View verification: mocked wire, no real ranking/card requests. */
export async function verifyLoadingPreview(){
const e=await System.import('cc'),modules=Array.from(System.entries()).map(([,m])=>m);
const exported=name=>{const m=modules.find(m=>m[name]);if(!m)throw Error(`Missing ${name}`);return m[name];};
const source=e.director.getScene().getChildByName('PlatformServices').getComponent('PlatformStartup');
const config=source.config,assets=source.getComponent('LobbyAssets');
source.unsubscribe?.();source.runtime?.stop();source.panel?.dispose();source.panel=null;
await new Promise((resolve,reject)=>e.director.loadScene('PlatformLobby',err=>err?reject(err):resolve()));
const sent=[],tips=[],checks=[];let receive,panel;
const assert=(ok,msg)=>{if(!ok)throw Error(msg);checks.push(msg);};
const runtime=new (exported('PlatformRuntime'))({
gameEntry:exported('createLoginStageGameEntry')(config.identity.gameid,()=>{}),resolveRuntimeConfig:async()=>config,
createWireClient:()=>({subscribe:cb=>{receive=cb;return ()=>{};},start:()=>{},stop:()=>{},send:p=>sent.push(p),switchServer:()=>{},reconnectCurrent:()=>{}}),
scene:{showLoading:()=>{},showLogin:()=>{},showLobby:()=>{},showRoom:()=>{},showReconnect:()=>{},showKicked:()=>{},showServerDenial:()=>{},showFatal:err=>{throw err;},
showTips:(msg,time,kind)=>{if(kind==='show_message')panel?.cancelRankRequest();tips.push(msg);}},
loadResources:async()=>{},waitForMinimumDisplay:async()=>{},getLoginDeviceSnapshot:()=>{throw Error('Fixture only');},
});
await runtime.start();receive({type:'open',server:config.servers[0]});
const packet=(rpc,data)=>{const message={route:'agent',rpc,data};panel?.requestReceived(message);receive({type:'message',message});};
packet('player_login',{state:0,playerid:7,nickname:'接线验证',avatar:'',sex:0,bean:0,roomcard:3,taskstate:0,score:0,sign:null,ip:'127.0.0.1',openid:'test',unionid:'test',invitecode:null});
const canvas=e.director.getScene().getChildByName('Canvas'),popup=new e.Node('OwnedVipTestPopup');popup.parent=canvas;popup.layer=canvas.layer;popup.addComponent(e.UITransform).setContentSize(1600,720);
const remote=exported('createRemoteConfig')({gameNoticeShow:-1,rankList:2,cdKey:2,vipRankLimit:3},config.identity);
const feedback=new (exported('FeedbackPanels'))(canvas,{tips:source.tipsPrefab,loading:source.waitPrefab,reconnect:source.reconnectPrefab,kick:source.kickPrefab});
panel=new (exported('LobbyPanel'))(canvas,popup,assets,config,exported('resolveLobbyPolicy')(remote),runtime.state,msg=>tips.push(msg),()=>{},()=>null,()=>{},
exported('resolveLobbyMenuConfig')(remote),{versionState:0,launchMode:0},action=>{if(action==='tasks')runtime.getPlayerTasks();},id=>runtime.claimTask(id),()=>{},undefined,undefined,
{rank:()=>runtime.getTreasureList(),redeem:card=>runtime.redeemCard(card),paste:async()=>'00Ab-12',loading:show=>feedback.loading(show),vipRank:limit=>runtime.getVipRankList(limit),joinOwned:code=>runtime.joinOwnedRoom(code,{city:'test'})});
popup.setSiblingIndex(canvas.children.length-1);
const off=runtime.subscribeState(state=>panel.render(state),err=>{throw err;});
const click=id=>panel.menuNodes.get(id).emit(e.Button.EventType.CLICK);
const overlay=feedback.loadingNode,animation=overlay.getChildByPath('group-40/载入动画').getComponent(e.Animation);
assert(animation&&animation.defaultClip?.name==='RequestLoading','Layer614 owns serialized RequestLoading clip');
click(17);assert(overlay.activeInHierarchy&&sent.at(-1).rpc==='get_player_task','task send shows Layer614');
assert(animation.getState('RequestLoading').isPlaying,'loading starts native Animation');
const angle=animation.node.angle;await new Promise(r=>setTimeout(r,200));assert(animation.node.angle!==angle,'animation advances rotation');
packet('get_player_task',{tasks:[{taskid:1,title:'test',award:1,finish:1,total:1,state:1}]});
assert(!overlay.active&&!animation.getState('RequestLoading').isPlaying,'task response hides overlay and stops clip');
panel.requestTask('award',()=>runtime.claimTask(1));assert(overlay.active&&sent.at(-1).rpc==='get_task_award','award send shows Layer614');
packet('get_task_award',{taskid:1,taskstate:0});assert(!overlay.active,'award response hides Layer614');
panel.close();click(3232);assert(overlay.active&&sent.at(-1).rpc==='getVipRankList','VIP rank send shows Layer614');
packet('getVipRankList',{state:1,error:'失败'});assert(!overlay.active,'VIP failure response hides Layer614');
panel.cancelRankRequest();click(3232);packet('getVipRankList',{list:[]});assert(!overlay.active,'empty VIP success hides Layer614');
click(3232);packet('show_message',{msg:'服务拒绝',time:0});assert(!overlay.active,'generic failure closes request loading');
click(17);panel.cancelTaskRequest();assert(!overlay.active,'timeout cancellation hides Layer614');
let caught=false;try{panel.requestTask('list',()=>{throw Error('send failed');});}catch{caught=true;}
assert(caught&&!overlay.active,'synchronous send failure hides Layer614');
click(17);panel.requestReceived({route:'agent',rpc:'get_player_task',data:null});assert(!overlay.active,'malformed task reply closes before parsing');panel.cancelTaskRequest();
click(3232);assert(overlay.active,'loading can restart');
globalThis.__loadingVerification={panel,runtime,feedback,checks,close:()=>{off();panel.dispose();runtime.stop();feedback.dispose();popup.destroy();}};
return {checks};
}
@@ -0,0 +1,70 @@
/** Run in the Cocos Game View; uses local text fixtures and never requests remote content. */
export async function verifyNoticeTabsPreview(e,boardId) {
const checks=[],assert=(value,message)=>{if(!value)throw Error(message);checks.push(message);};
const load=id=>new Promise((resolve,reject)=>e.assetManager.loadAny(id,(err,a)=>err?reject(err):resolve(a)));
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
const asset=await load(boardId),root=e.instantiate(asset);root.parent=e.director.getScene();
const host=new e.Node('NoticeFlowVerification');host.parent=e.director.getScene();
let panel;
try {
assert(root.children.length===1&&root.children[0].name==='group-12','standalone prefab contains only group-12');
const view=root.getChildByPath('group-12/NoticeTabs').getComponent('NoticeTabsView');
assert(view&&view.tabs.length===3,'three native tabs are serialized');
assert(view.getComponent(e.ToggleContainer)?.allowSwitchOff===false,'container enforces single selection');
const selected=[];let release=view.bindPages([true,true,true],index=>selected.push(index));await sleep(60);
const state=index=>{
assert(view.tabs.every((tab,i)=>tab.isChecked===(i===index)),'exactly the selected tab is checked');
assert(view.backgrounds.every((s,i)=>s.spriteFrame.name===(i===index?'00216_1':'00216_2')),'backgrounds follow original selected frames');
assert(view.titles.every((s,i)=>s.spriteFrame.name==='00217_'+(i*2+(i===index?2:1))),'titles follow original selected frames');
};
state(0);assert(selected.join(',')==='0','opening shows first page once');
view.tabs[1].node.emit(e.Button.EventType.CLICK);state(1);assert(selected.at(-1)===1,'native click selects second content page');
view.tabs[2].node.emit(e.Button.EventType.CLICK);state(2);assert(selected.at(-1)===2,'native click selects third content page');
view.tabs[2].node.emit(e.Button.EventType.CLICK);state(2);
release();const count=selected.length;view.tabs[0].node.emit(e.Button.EventType.CLICK);assert(selected.length===count,'released callback does not run');
release=view.bindPages([true,false,true],index=>selected.push(index));await sleep(60);state(0);
assert(!view.tabs[1].node.active,'empty second page hides its entire tab');
const first=view.tabs[0].node,third=view.tabs[2].node;
assert(Math.abs(first.position.y-third.position.y-first.getComponent(e.UITransform).height-view.getComponent(e.Layout).spacingY)<.1,'hidden middle tab leaves no layout gap');
view.tabs[2].node.emit(e.Button.EventType.CLICK);state(2);assert(selected.at(-1)===2,'hidden tab does not change page identity');release();
const startup=await load('1c10c2ee-adc2-496f-9005-d3e0e6ebbb56'),refs=startup.scene.getComponentInChildren('LobbyAssets');
assert(refs.noticeBoard.uuid===boardId,'startup references the standalone board');
assert(refs.ownedRoomList.data.getChildByName('group-98')&&refs.qrCode.data.getChildByName('group-42'),'independent room list and QR page remain wired');
assert(!refs.qrCode.data.getChildByName('group-12'),'QR prefab does not duplicate group-12');
const LobbyPanel=Array.from(System.entries()).map(([,module])=>module).find(module=>module.LobbyPanel)?.LobbyPanel;
assert(LobbyPanel,'LobbyPanel module is available');panel=Object.create(LobbyPanel.prototype);
panel.assets={noticeBoard:asset};panel.model={canAct:true};panel.disposed=false;panel.modal=null;panel.modalReleases=[];
panel.policy={notices:[{type:0,text:'第一页',image:'',url:''},{type:0,text:'',image:'',url:''},{type:0,text:'第三页',image:'',url:''}]};
panel.close=()=>{for(const off of panel.modalReleases.splice(0))off();panel.modal?.destroy();panel.modal=null;panel.modalKind='';};
panel.open=(prefab,kind)=>{panel.close();panel.modal=e.instantiate(prefab);panel.modal.parent=host;panel.modalKind=kind;return panel.modal;};
panel.openNotice();await sleep(60);
const board=panel.modal,g=board.getChildByName('group-12'),actual=g.getChildByName('NoticeTabs').getComponent('NoticeTabsView');
const text=g.getChildByPath('NoticeViewport/NoticeContent/通知内容').getComponent(e.Label);
assert(text.string==='第一页','actual notice entry renders page one');
actual.tabs[2].node.emit(e.Button.EventType.CLICK);assert(text.string==='第三页','actual tab event renders matching content');
assert(!actual.tabs[1].node.active,'actual entry uses source visibility rules');
g.getChildByName('通知关闭').emit(e.Button.EventType.CLICK);assert(panel.modal===null,'close button closes independent board');
panel.openNotice();await sleep(40);
assert(panel.modal.getChildByPath('group-12/NoticeTabs').getComponent('NoticeTabsView').tabs[0].isChecked,'reopening resets selection');
// Route coordinate touches through Cocos hit testing, including the background behind controls.
root.active=false;
const canvas=e.director.getScene().getComponentInChildren(e.Canvas);
const mount=async()=>{panel.openNotice();panel.modal.parent=canvas.node;panel.modal.setPosition(0,0,0);const widget=panel.modal.getComponent(e.Widget);widget.target=canvas.node;widget.updateAlignment();await sleep(70);};
let touchId=100;
const tap=async(node,dx=0,dy=0)=>{
const position=node.worldPosition.clone();position.x+=dx;position.y+=dy;
const screen=canvas.cameraComponent.worldToScreen(position),touch=new e.Touch(screen.x,screen.y,touchId++);
for(const type of [e.Input.EventType.TOUCH_START,e.Input.EventType.TOUCH_END])e.input._dispatchEventTouch(new e.EventTouch([touch],false,type,[touch]));
await sleep(25);
};
await mount();let surface=panel.modal.getChildByName('group-12');
await tap(surface.getChildByName('通知背景'),-350,-200);assert(panel.modal!==null,'panel blank area does not close the board');
await tap(surface.getChildByName('通知标题内容'));assert(panel.modal!==null,'title does not close the board');
await tap(surface.getChildByPath('NoticeViewport/NoticeContent/通知内容'),30,-10);assert(panel.modal!==null,'content does not close the board');
await tap(surface.getChildByPath('NoticeTabs/NoticeTab3'));assert(panel.modal!==null&&surface.getChildByPath('NoticeTabs').getComponent('NoticeTabsView').tabs[2].isChecked,'coordinate tab touch selects without closing');
await tap(surface.getChildByName('通知关闭'));assert(panel.modal===null,'coordinate close-button touch closes the board');
await mount();surface=panel.modal.getChildByName('group-12');
await tap(surface.getChildByName('通知遮罩'),-700,0);assert(panel.modal===null,'coordinate backdrop touch closes the board');
panel.close();return {checks};
} finally {panel?.close();root.destroy();host.destroy();}
}
@@ -0,0 +1,114 @@
/** Run in Cocos Game View, with the imported cc module and prefab UUIDs from asset-db.
* Uses real Animation states and isolated nodes; no wire messages or application settings.
*/
export async function verifyOptionalAnimationPreview(e, prefabIds) {
const checks=[];
const assert=(ok,message)=>{if(!ok)throw Error(message);checks.push(message);};
const load=uuid=>new Promise((resolve,reject)=>e.assetManager.loadAny(uuid,(err,asset)=>err?reject(err):resolve(asset)));
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
const root=new e.Node('OptionalAnimationVerification');root.parent=e.director.getScene();
const disposable=[];
const restoreStorage=[];
try {
const menu=await load(prefabIds.menu),notice=await load(prefabIds.notice),settings=await load(prefabIds.settings);
const find=(node,name)=>{if(node.name===name)return node;for(const child of node.children){const found=find(child,name);if(found)return found;}return null;};
const cases=[[menu,['Spirit479','Spirit3120','Spirit76','Spirit11','Spirit12','Spirit14','Spirit17','Spirit13','Spirit7']],
[notice,['Spirit3121','Spirit3017','Spirit5','Spirit235','Spirit604','Spirit3057','Spirit15']]];
for(const [asset,names] of cases)for(const name of names){
const node=find(asset.data,name);
assert(node?.getComponent(e.Animation)&&node.getComponent('OptionalUiAnimation'),`${asset.name}/${name} has authored animation support`);
}
for(const name of ['音乐底','音效底','Spirit357','Spirit388']){
const node=find(settings.data,name);
assert(node?.getComponent(e.Animation)&&node.getComponent('OptionalSwitchAnimation'),`settings/${name} has authored switch animation support`);
}
const Switch=e.js.getClassByName('OptionalSwitchAnimation');
const bareSwitch=new e.Node('BareSwitch');bareSwitch.addComponent(Switch);bareSwitch.parent=root;
assert(bareSwitch.getComponent(e.Animation)&&bareSwitch.getComponent(e.Sprite),'mounting switch support provides both required components');
const horn=find(menu.data,'Spirit479'),hornClip=horn.getComponent(e.Animation).defaultClip;
assert(hornClip&&hornClip.wrapMode===e.AnimationClip.WrapMode.Loop,'horn owns a looping clip');
const idle=e.instantiate(find(menu.data,'Spirit11'));idle.parent=root;
const idleSprite=idle.getComponent(e.Sprite),original=idleSprite.spriteFrame;
const idleAnimation=idle.getComponent(e.Animation);
await sleep(80);
assert(idleSprite.spriteFrame===original&&!idleAnimation.defaultClip,'unassigned clip preserves the authored sprite');
idle.active=false;idleAnimation.defaultClip=hornClip;idle.active=true;
await sleep(100);
const idleState=idleAnimation.getState(hornClip.name);
assert(idleState.isPlaying&&idleState.time>0,'assigned clip starts on show and advances');
idle.active=false;
assert(!idleState.isPlaying,'hiding stops the animation');
const stoppedTime=idleState.time;await sleep(80);
assert(idleState.time===stoppedTime,'hidden animation does not advance');
idle.active=true;await sleep(40);
assert(idleState.isPlaying&&idleState.time<0.3,'showing again restarts the clip');
// Manual sampling verifies the authored frame schedule, independent of wall-clock jitter.
const sampleNode=e.instantiate(horn);sampleNode.parent=root;
const sampleAnimation=sampleNode.getComponent(e.Animation);sampleAnimation.stop();
const hornState=sampleAnimation.getState(hornClip.name),sampleSprite=sampleNode.getComponent(e.Sprite);
const samples=[];
for(const time of [0,0.49,0.5,0.99,1,1.99]){hornState.time=time;hornState.sample();samples.push(sampleSprite.spriteFrame);}
assert(samples[0]===samples[1]&&samples[0]!==samples[2]&&samples[2]===samples[3]&&samples[0]===samples[4]&&samples[0]===samples[5],
'horn preserves the original two-second frame schedule');
const onFrame=samples[0],offFrame=samples[2];
const switchNode=e.instantiate(find(settings.data,'音乐底'));switchNode.parent=root;
const view=switchNode.getComponent(Switch),sprite=switchNode.getComponent(e.Sprite),animation=switchNode.getComponent(e.Animation);
view.setState(true,onFrame,false);view.setState(false,offFrame,true);
assert(sprite.spriteFrame===offFrame,'switch without clips changes immediately');
// Intentionally author the wrong final frame and Loop: the state remains authoritative.
const onClip=e.AnimationClip.createWithSpriteFrames([offFrame,offFrame],20);onClip.name='TestSwitchOn';onClip.wrapMode=e.AnimationClip.WrapMode.Loop;
const offClip=e.AnimationClip.createWithSpriteFrames([onFrame,onFrame],20);offClip.name='TestSwitchOff';
disposable.push(onClip,offClip);view.onClip=onClip;view.offClip=offClip;
view.setState(true,onFrame,false);
assert(sprite.spriteFrame===onFrame,'initial state does not play a transition');
view.setState(false,offFrame,true);await sleep(35);
assert(animation.getState('TestSwitchOff').isPlaying,'off transition starts');
view.setState(true,onFrame,true);await sleep(250);
assert(sprite.spriteFrame===onFrame&&!animation.getState('TestSwitchOn').isPlaying,'rapid reversal finishes at latest state and plays once');
view.setState(false,offFrame,true);switchNode.active=false;
assert(sprite.spriteFrame===offFrame&&!animation.getState('TestSwitchOff').isPlaying,'hide cancels transition and restores latest state');
switchNode.active=true;await sleep(60);
assert(sprite.spriteFrame===offFrame&&!animation.getState('TestSwitchOff').isPlaying,'reopen preserves state without replaying stale transition');
view.offClip=null;view.setState(true,onFrame,true);view.setState(false,offFrame,true);await sleep(180);
assert(sprite.spriteFrame===offFrame,'missing destination clip cancels the old transition and keeps the new state');
// Exercise the real settings click handlers against isolated storage keys.
const LobbyPanel=Array.from(System.entries()).map(([,m])=>m).find(m=>m.LobbyPanel)?.LobbyPanel;
assert(LobbyPanel,'LobbyPanel is loaded for settings integration');
const panel=Object.create(LobbyPanel.prototype),tips=[];
panel.assets={settings,switches:[onFrame,offFrame]};panel.config={identity:{gameid:987654321,agentid:765432109}};
panel.context={launchMode:0};panel.model={canAct:true};panel.disposed=false;panel.modalReleases=[];
panel.tip=message=>tips.push(message);
panel.open=()=>{panel.modal=e.instantiate(settings);panel.modal.parent=root;return panel.modal;};
panel.close=()=>{for(const off of panel.modalReleases)off();panel.modalReleases=[];panel.modal.destroy();};
const musicKey='tsgame_bgm_987654321',soundKey='tsgame_sound_987654321765432109';
for(const key of [musicKey,soundKey]){
const previous=localStorage.getItem(key);restoreStorage.push(()=>previous===null?localStorage.removeItem(key):localStorage.setItem(key,previous));
localStorage.setItem(key,'1');
}
panel.openSettings();
let group=panel.modal.getChildByName('group-6');
const music=group.getChildByName('音乐底'),sound=group.getChildByName('音效底');
const musicView=music.getComponent(Switch);musicView.onClip=onClip;musicView.offClip=offClip;
assert(music.getComponent(e.Sprite).spriteFrame===onFrame&&sound.getComponent(e.Sprite).spriteFrame===onFrame,'settings initialize from persisted state');
music.emit(e.Button.EventType.CLICK);
assert(localStorage.getItem(musicKey)==='0'&&music.getComponent(e.Animation).getState('TestSwitchOff').isPlaying,'music click persists state and starts configured transition');
music.emit(e.Button.EventType.CLICK);await sleep(200);
assert(localStorage.getItem(musicKey)==='1'&&music.getComponent(e.Sprite).spriteFrame===onFrame,'rapid music clicks retain the latest persisted state');
sound.emit(e.Button.EventType.CLICK);
assert(localStorage.getItem(soundKey)==='0'&&sound.getComponent(e.Sprite).spriteFrame===offFrame,'sound click without a clip keeps immediate switching');
for(const name of ['Spirit357','Spirit388']){
const node=group.getChildByName(name),frame=node.getComponent(e.Sprite).spriteFrame;
node.emit(e.Button.EventType.CLICK);
assert(node.getComponent(e.Sprite).spriteFrame===frame&&tips.at(-1)==='原生震动与语音开关尚未接入',`${name} does not invent an unsupported switch state`);
}
assert(tips.length===2,'settings click handlers produce no unexpected error tips');
panel.close();panel.openSettings();group=panel.modal.getChildByName('group-6');
assert(group.getChildByName('音乐底').getComponent(e.Sprite).spriteFrame===onFrame&&group.getChildByName('音效底').getComponent(e.Sprite).spriteFrame===offFrame,
'reopening settings restores the latest music and sound states');
panel.close();
return {checks};
} finally {
for(const restore of restoreStorage)restore();
root.destroy();await sleep(50);for(const clip of disposable)clip.destroy();
}
}
@@ -0,0 +1,61 @@
/** Cocos Game View verification: mocked wire, no real ranking/card requests. */
export async function verifyOwnedVipPreview(){
const e=await System.import('cc'),modules=Array.from(System.entries()).map(([,m])=>m);
const exported=name=>{const m=modules.find(m=>m[name]);if(!m)throw Error(`Missing ${name}`);return m[name];};
const source=e.director.getScene().getChildByName('PlatformServices').getComponent('PlatformStartup');
const config=source.config,assets=source.getComponent('LobbyAssets');
source.unsubscribe?.();source.runtime?.stop();source.panel?.dispose();source.panel=null;
await new Promise((resolve,reject)=>e.director.loadScene('PlatformLobby',err=>err?reject(err):resolve()));
const sent=[],tips=[],checks=[];let receive,panel;
const assert=(ok,msg)=>{if(!ok)throw Error(msg);checks.push(msg);};
const runtime=new (exported('PlatformRuntime'))({
gameEntry:exported('createLoginStageGameEntry')(config.identity.gameid,()=>{}),resolveRuntimeConfig:async()=>config,
createWireClient:()=>({subscribe:cb=>{receive=cb;return ()=>{};},start:()=>{},stop:()=>{},send:p=>sent.push(p),switchServer:()=>{},reconnectCurrent:()=>{}}),
scene:{showLoading:()=>{},showLogin:()=>{},showLobby:()=>{},showRoom:()=>{},showReconnect:()=>{},showKicked:()=>{},showServerDenial:()=>{},showFatal:err=>{throw err;},
showTips:(msg,time,kind)=>{if(kind==='show_message')panel?.cancelRankRequest();tips.push(msg);}},
loadResources:async()=>{},waitForMinimumDisplay:async()=>{},getLoginDeviceSnapshot:()=>{throw Error('Fixture only');},
});
await runtime.start();receive({type:'open',server:config.servers[0]});
const packet=(rpc,data)=>receive({type:'message',message:{route:'agent',rpc,data}});
packet('player_login',{state:0,playerid:7,nickname:'接线验证',avatar:'',sex:0,bean:0,roomcard:3,taskstate:0,score:0,sign:null,ip:'127.0.0.1',openid:'test',unionid:'test',invitecode:null});
const canvas=e.director.getScene().getChildByName('Canvas'),popup=new e.Node('OwnedVipTestPopup');popup.parent=canvas;popup.layer=canvas.layer;popup.addComponent(e.UITransform).setContentSize(1600,720);
const remote=exported('createRemoteConfig')({gameNoticeShow:-1,rankList:2,cdKey:2,vipRankLimit:3},config.identity);
panel=new (exported('LobbyPanel'))(canvas,popup,assets,config,exported('resolveLobbyPolicy')(remote),runtime.state,msg=>tips.push(msg),()=>{},()=>null,()=>{},
exported('resolveLobbyMenuConfig')(remote),{versionState:0,launchMode:0},()=>{},()=>{},()=>{},undefined,undefined,
{rank:()=>runtime.getTreasureList(),redeem:card=>runtime.redeemCard(card),paste:async()=>'00Ab-12',loading:()=>{},vipRank:limit=>runtime.getVipRankList(limit),joinOwned:code=>runtime.joinOwnedRoom(code,{city:'test'})});
popup.setSiblingIndex(canvas.children.length-1);
const off=runtime.subscribeState(state=>panel.render(state),err=>{throw err;});
const click=id=>panel.menuNodes.get(id).emit(e.Button.EventType.CLICK);
const key='tsgame_roomlist_'+config.identity.gameid+config.identity.agentid,previous=localStorage.getItem(key);
try{
const before=tips.length;for(const id of [10,286,287,3161,508,509,496,3162])click(id);
assert(tips.length===before&&sent.length===0&&panel.modal===null,'retired payment entries neither notify nor send');
localStorage.setItem(key,JSON.stringify({data:[100001,100002,100003,100004,100005,100006]}));
click(99);assert(panel.modalKind==='my-rooms','Spirit99 opens authored owned room panel');
let group=panel.modal.getChildByName('group-98');const rows=panel.modal.getComponent('OwnerRoomListView').content.children;
assert(rows.length===6,'owned rooms show all cached entries');
assert(rows[0].getChildByName('Digits').children.length===6,'owned room uses six original digit sprites');
rows[1].emit(e.Button.EventType.CLICK);assert(panel.modal===null,'owned room click closes list');
const join=sent.at(-1);assert(join.rpc==='self_join_room'&&join.data.roomcode===100002&&join.data.joinmode===1,'owned room click sends original join mode');
assert(join.data.location.city==='test'&&join.data.ip==='127.0.0.1','owned room join retains native location and login IP');
localStorage.setItem(key,JSON.stringify({data:[]}));click(99);assert(panel.modalKind==='my-rooms','empty owned room list still opens');
panel.modal.getChildByPath('group-98/Spirit103').emit(e.Button.EventType.CLICK);assert(panel.modal===null,'owned room shade closes');
const count=sent.length;click(3232);click(3232);
assert(sent.length===count+1&&sent.at(-1).rpc==='getVipRankList','VIP rank suppresses duplicate request');
assert(JSON.stringify(sent.at(-1).data)===JSON.stringify({agentid:config.identity.agentid,limit:3}),'VIP rank uses configured limit and exact fields');
packet('get_treasurelist',{list:[]});assert(panel.rankPending==='vip-rank','wealth response cannot consume pending VIP request');
const list=[{name:'VIP一',headimg:'',playerid:1,star:88,signature:'签名一',destwo:'附注',notice:'公告一'},{name:'VIP二',headimg:'',playerid:2,star:66,signature:'签名二',notice:'公告二'}];
packet('getVipRankList',{list});assert(panel.modalKind==='vip-rank','VIP response opens shared rank view');
group=panel.modal.getChildByName('group-84');assert(group.getChildByName('Spirit3234').getComponent(e.Sprite).spriteFrame===assets.vipRankTitle,'VIP title uses original frame');
assert(group.getChildByName('Spirit640').getComponent(e.Label).string==='公告:\n(附注)\n公告一','VIP detail renders notice and supplemental text');
group.getComponentInChildren(e.ScrollView).content.children[1].emit(e.Button.EventType.CLICK);
assert(group.getChildByName('Spirit640').getComponent(e.Label).string==='公告:\n公告二','VIP row selection updates announcement');
panel.close();click(3232);packet('getVipRankList',{});assert(panel.modal===null&&!panel.rankPending,'missing VIP list completes silently like legacy');
click(3232);packet('getVipRankList',{list:[]});assert(panel.modal===null&&!panel.rankPending,'empty VIP list stays closed');
click(3232);packet('getVipRankList',{list});
await new Promise(resolve=>e.director.once(e.Director.EVENT_AFTER_DRAW,resolve));
globalThis.__ownedVipVerification={panel,runtime,checks,close:()=>{off();panel.dispose();runtime.stop();popup.destroy();}};
return {checks,packets:sent.map(p=>({rpc:p.rpc,fields:Object.keys(p.data)}))};
}finally{if(previous===null)localStorage.removeItem(key);else localStorage.setItem(key,previous);}
}
@@ -0,0 +1,47 @@
/** Real Cocos layout and coordinate-input checks. Only a dedicated local fixture history is written. */
export async function verifyOwnerRoomPreview(e,listId){
const checks=[],assert=(v,m)=>{if(!v)throw Error(m);checks.push(m);},sleep=ms=>new Promise(r=>setTimeout(r,ms));
const load=id=>new Promise((r,j)=>e.assetManager.loadAny(id,(err,a)=>err?j(err):r(a)));
const source=await load('1c10c2ee-adc2-496f-9005-d3e0e6ebbb56'),refs=source.scene.getComponentInChildren('LobbyAssets');
const asset=await load(listId),canvas=e.director.getScene().getComponentInChildren(e.Canvas);
assert(refs.ownedRoomList.uuid===listId,'startup points at independent room list');
assert(!refs.qrCode.data.getChildByName('group-98')&&refs.qrCode.data.getChildByName('group-42'),'room and QR groups use separate prefabs');
const view=asset.data.getComponent('OwnerRoomListView');
assert(asset.data.children.length===1&&asset.data.children[0].name==='group-98','room prefab contains only group-98');
assert(view.itemPrefab.data.name==='OwnerRoomItem','list references the requested OwnerRoomItem prefab');
const LobbyPanel=Array.from(System.entries()).map(([,m])=>m).find(m=>m.LobbyPanel).LobbyPanel,panel=Object.create(LobbyPanel.prototype),joined=[];
panel.assets={ownedRoomList:asset,ownedRoomDigits:refs.ownedRoomDigits};panel.model={canAct:true};panel.config={identity:{gameid:'__owner_preview_',agentid:'local'}};
panel.disposed=false;panel.modal=null;panel.modalReleases=[];panel.extras={joinOwned:code=>joined.push(code)};
panel.close=()=>{for(const off of panel.modalReleases.splice(0))off();panel.modal?.destroy();panel.modal=null;};
panel.open=(prefab,kind)=>{panel.close();const n=e.instantiate(prefab);n.parent=canvas.node;n.setPosition(0,0,0);const w=n.getComponent(e.Widget);w.target=canvas.node;w.updateAlignment();panel.modal=n;panel.modalKind=kind;return n;};
const key='tsgame_roomlist___owner_preview_local',previous=localStorage.getItem(key),codes=Array.from({length:9},(_,i)=>100001+i);
let id=200;
const send=(touch,type)=>e.input._dispatchEventTouch(new e.EventTouch([touch],false,type,[touch]));
const touchAt=(node,dx=0,dy=0)=>{const p=node.worldPosition.clone();p.x+=dx;p.y+=dy;const s=canvas.cameraComponent.worldToScreen(p);return new e.Touch(s.x,s.y,id++);};
const tap=async(node,dx=0,dy=0)=>{const t=touchAt(node,dx,dy);send(t,e.Input.EventType.TOUCH_START);send(t,e.Input.EventType.TOUCH_END);await sleep(30);};
try{
localStorage.setItem(key,JSON.stringify({data:codes}));panel.openOwnedRooms();await sleep(100);
let v=panel.modal.getComponent('OwnerRoomListView'),rows=v.content.children;
assert(rows.length===9,'more than five cached records are displayed');
assert(v.scroll.getMaxScrollOffset().y>0,'long room list is scrollable');
assert(rows.every((n,i)=>n.getChildByName('Digits').children.map(d=>refs.ownedRoomDigits.indexOf(d.getComponent(e.Sprite).spriteFrame)).join('')===String(codes[i])),'all room numbers retain digit artwork and ordering');
const layout=v.content.getComponent(e.Layout);
assert(rows.slice(1).every((n,i)=>Math.abs(rows[i].position.y-n.position.y-80-layout.spacingY)<.1),'room spacing comes from Layout');
await tap(panel.modal.getChildByPath('group-98/房间列表背景'),0,240);assert(panel.modal!==null,'panel header blank area does not close');
const t=touchAt(rows[1]);send(t,e.Input.EventType.TOUCH_START);const p=t.getLocation();
t.setTouchInfo(t.getID(),p.x,p.y+120);send(t,e.Input.EventType.TOUCH_MOVE);send(t,e.Input.EventType.TOUCH_END);await sleep(80);
assert(panel.modal!==null&&joined.length===0,'dragging a row never joins or closes');
assert(v.scroll.getScrollOffset().y>0,'coordinate drag moves scroll content');
v.scroll.stopAutoScroll();v.scroll.scrollToBottom(0);await sleep(60);
await tap(rows.at(-1));assert(joined.at(-1)===codes.at(-1)&&panel.modal===null,'last scrolled row joins its own room and closes');
panel.openOwnedRooms();await sleep(60);v=panel.modal.getComponent('OwnerRoomListView');
assert(Math.abs(v.scroll.getScrollOffset().y)<.1,'reopening starts at top');
v.render([],refs.ownedRoomDigits);await sleep(40);assert(v.content.children.length===0,'empty update removes all rows');
assert(v.content.getComponent(e.UITransform).height===0,'empty update removes stale scroll height');
v.render(codes,refs.ownedRoomDigits);await sleep(40);assert(v.content.children.length===9&&v.scroll.getMaxScrollOffset().y>0,'refilling empty list restores layout and scrolling');
panel.close();localStorage.setItem(key,JSON.stringify({data:[]}));panel.openOwnedRooms();await sleep(60);
assert(panel.modal.getComponent('OwnerRoomListView').content.children.length===0,'empty history opens an empty list');
await tap(panel.modal.getChildByPath('group-98/Spirit103'),600,0);assert(panel.modal===null,'outside shade closes room list');
return {checks};
}finally{panel.close();if(previous===null)localStorage.removeItem(key);else localStorage.setItem(key,previous);}
}
@@ -0,0 +1,72 @@
/** Execute in Cocos Game View. All input fixtures stay local; no server submission. */
export async function verifyProfilePreview(e,profileId) {
const checks=[],assert=(ok,message)=>{if(!ok)throw Error(message);checks.push(message);};
const load=id=>new Promise((resolve,reject)=>e.assetManager.loadAny(id,(err,a)=>err?reject(err):resolve(a)));
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
const asset=await load(profileId),root=e.instantiate(asset);root.parent=e.director.getScene();
const host=new e.Node('ProfileFlowVerification');host.parent=e.director.getScene();
try {
const view=root.getComponent('ProfileInfoListView');
assert(view,'profile prefab owns ProfileInfoListView');
assert(view.textItemPrefab&&view.authPrefab,'profile owns text-item and auth prefab references');
view.render(['昵称','ID:42','IP:127.0.0.1','积分:0','星星:10','城市','个性签名:'],'短签名');
await sleep(80);
const content=view.content,layout=content.getComponent(e.Layout);
const checkSpacing=()=>{
const children=content.children.filter(n=>n.active);
for(let i=1;i<children.length;i++){
const prev=children[i-1],next=children[i],pu=prev.getComponent(e.UITransform),nu=next.getComponent(e.UITransform);
const bottom=prev.position.y-pu.height*pu.anchorY,top=next.position.y+nu.height*(1-nu.anchorY);
assert(Math.abs(bottom-top-layout.spacingY)<0.1,'visible rows use the container spacing');
}
};
checkSpacing();const initialHeight=content.getComponent(e.UITransform).height;
view.render(['很长的昵称'.repeat(25),'ID:42','个性签名:'],'长签名内容'.repeat(100));await sleep(80);checkSpacing();
assert(content.children.filter(n=>n.active).length===4,'removed fields leave no empty rows');
assert(content.children[0].getComponent(e.UITransform).height>30,'long nickname wraps and increases its row height');
assert(view.signatureBox.height>=view.signature.node.getComponent(e.UITransform).height+20,'signature background grows with its text');
assert(content.getComponent(e.UITransform).height>initialHeight&&view.scroll.getMaxScrollOffset().y>0,'long content becomes scrollable');
view.scroll.scrollToBottom(0);await sleep(30);
view.render(['昵称','个性签名:'],'短签名');await sleep(60);checkSpacing();
assert(content.getComponent(e.UITransform).height<initialHeight,'shorter content shrinks the list again');
assert(view.scroll.getScrollOffset().y<=Math.max(0,view.scroll.getMaxScrollOffset().y)+0.1,'content shrink clamps scrolling');
const LobbyPanel=Array.from(System.entries()).map(([,m])=>m).find(m=>m.LobbyPanel)?.LobbyPanel;
assert(LobbyPanel,'LobbyPanel is loaded');
const panel=Object.create(LobbyPanel.prototype),tips=[];
panel.assets={profile:asset};panel.disposed=false;panel.modal=null;panel.modalReleases=[];
panel.policy={starName:'星星'};panel.model={nickname:'资料接线测试',playerid:42,ip:'127.0.0.1',score:0,bean:10,sign:'签名测试',canAct:true};
panel.avatarFrame=asset.data.getChildByPath('group-18/Spirit118').getComponent(e.Sprite).spriteFrame;
panel.location=()=>null;panel.tip=message=>tips.push(message);
panel.close=()=>{for(const off of panel.modalReleases.splice(0))off();panel.modal?.destroy();panel.modal=null;panel.modalKind='';};
panel.open=(prefab,kind)=>{panel.close();panel.modal=e.instantiate(prefab);panel.modal.parent=host;panel.modalKind=kind;return panel.modal;};
panel.openProfile();
const profile=panel.modal,profileView=profile.getComponent('ProfileInfoListView');
assert(profileView.content.children[0].getComponent(e.Label).string==='资料接线测试'&&profileView.signature.string==='签名测试','profile renders data through the new hierarchy');
const auth=()=>{
panel.modal.getChildByPath('group-18/Spirit484').emit(e.Button.EventType.CLICK);
assert(panel.modal?.name==='Layer18_Auth'&&panel.modalKind==='auth','real-name button opens the auth prefab');
const group=panel.modal.getChildByName('group-57');
return {group,name:group.getChildByName('NameInput').getComponent(e.EditBox),id:group.getChildByName('IdInput').getComponent(e.EditBox),submit:group.getChildByName('Spirit493')};
};
let inputs=auth();await sleep(40);
assert(!e.isValid(profile),'opening auth closes the profile');
assert(inputs.name.string===''&&inputs.id.string==='','auth opens with empty inputs');
assert(inputs.id.inputMode===e.EditBox.InputMode.SINGLE_LINE&&inputs.id.inputFlag===e.EditBox.InputFlag.DEFAULT,'ID input allows the X checksum character');
for(const edit of [inputs.name,inputs.id])for(const label of [edit.textLabel,edit.placeholderLabel]){
const ui=label.node.getComponent(e.UITransform);
assert(Math.abs(label.node.position.y+(0.5-ui.anchorY)*ui.height)<0.1&&label.verticalAlign===e.Label.VerticalAlign.CENTER,'input text stays vertically centered inside its hit area');
}
inputs.name.string='张';inputs.id.string='11010519491231002X';inputs.submit.emit(e.Button.EventType.CLICK);
assert(tips.at(-1)==='姓名不正确!'&&panel.modal!==null,'invalid name shows source tip and keeps auth open');
inputs.name.string='测试';inputs.id.string='110105194912310020';inputs.submit.emit(e.Button.EventType.CLICK);
assert(tips.at(-1)==='身份证信息不正确!'&&panel.modal!==null,'invalid ID shows source tip and keeps auth open');
inputs.id.string='11010519491231002X';inputs.submit.emit(e.Button.EventType.CLICK);
assert(tips.at(-1)==='认证成功!'&&panel.modal===null,'valid local input closes auth and shows source success tip');
panel.openProfile();inputs=auth();
assert(inputs.name.string===''&&inputs.id.string==='','reopening does not retain identity inputs');
inputs.group.getChildByName('Spirit485').emit(e.Button.EventType.CLICK);
assert(panel.modal===null,'auth backdrop closes the panel');
assert(tips.length===3,'flow produced only the three expected validation tips');
return {checks};
} finally {root.destroy();host.destroy();await sleep(50);}
}
@@ -0,0 +1,64 @@
/** Cocos Game View verification: mocked wire, no real ranking/card requests. */
export async function verifyRankCardPreview(){
const e=await System.import('cc'),modules=Array.from(System.entries()).map(([,m])=>m);
const exported=name=>{const m=modules.find(m=>m[name]);if(!m)throw Error(`Missing ${name}`);return m[name];};
const source=e.director.getScene().getChildByName('PlatformServices').getComponent('PlatformStartup');
const config=source.config,assets=source.getComponent('LobbyAssets');
source.unsubscribe?.();source.runtime?.stop();source.panel?.dispose();source.panel=null;
await new Promise((resolve,reject)=>e.director.loadScene('PlatformLobby',err=>err?reject(err):resolve()));
const sent=[],tips=[],checks=[];let receive,panel;
const assert=(ok,msg)=>{if(!ok)throw Error(msg);checks.push(msg);};
const runtime=new (exported('PlatformRuntime'))({
gameEntry:exported('createLoginStageGameEntry')(config.identity.gameid,()=>{}),resolveRuntimeConfig:async()=>config,
createWireClient:()=>({subscribe:cb=>{receive=cb;return ()=>{};},start:()=>{},stop:()=>{},send:p=>sent.push(p),switchServer:()=>{},reconnectCurrent:()=>{}}),
scene:{showLoading:()=>{},showLogin:()=>{},showLobby:()=>{},showRoom:()=>{},showReconnect:()=>{},showKicked:()=>{},showServerDenial:()=>{},showFatal:err=>{throw err;},
showTips:(msg,time,kind)=>{if(kind==='show_message')panel?.cancelRankRequest();tips.push(msg);}},
loadResources:async()=>{},waitForMinimumDisplay:async()=>{},getLoginDeviceSnapshot:()=>{throw Error('Fixture only');},
});
await runtime.start();receive({type:'open',server:config.servers[0]});
const packet=(rpc,data)=>receive({type:'message',message:{route:'agent',rpc,data}});
packet('player_login',{state:0,playerid:7,nickname:'接线验证',avatar:'',sex:0,bean:0,roomcard:3,taskstate:0,score:0,sign:null,ip:'127.0.0.1',openid:'test',unionid:'test',invitecode:null});
const canvas=e.director.getScene().getChildByName('Canvas'),popup=new e.Node('RankCardTestPopup');popup.parent=canvas;popup.layer=canvas.layer;popup.addComponent(e.UITransform).setContentSize(1600,720);
const remote=exported('createRemoteConfig')({gameNoticeShow:-1,rankList:2,cdKey:2},config.identity);
panel=new (exported('LobbyPanel'))(canvas,popup,assets,config,exported('resolveLobbyPolicy')(remote),runtime.state,msg=>tips.push(msg),()=>{},()=>null,()=>{},
exported('resolveLobbyMenuConfig')(remote),{versionState:0,launchMode:0},()=>{},()=>{},()=>{},undefined,undefined,
{rank:()=>runtime.getTreasureList(),redeem:card=>runtime.redeemCard(card),paste:async()=>'00Ab-12'});
popup.setSiblingIndex(canvas.children.length-1);
const off=runtime.subscribeState(state=>panel.render(state),err=>{throw err;});
const click=id=>panel.menuNodes.get(id).emit(e.Button.EventType.CLICK);
click(3233);click(3233);assert(sent.length===1&&sent[0].rpc==='get_treasurelist','rank query suppresses duplicate clicks');
assert(panel.modal===null,'rank waits for server');
const list=Array.from({length:8},(_,i)=>({name:`测试${i+1}`,headimg:'',playerid:i+1,star:100-i,signature:`签名${i+1}`}));
packet('get_treasurelist',{list});assert(panel.modalKind==='rank','server opens authored rank prefab');
let group=panel.modal.getChildByName('group-84'),rows=group.getComponentInChildren(e.ScrollView).content;
assert(rows.children.length===8,'all rank rows rendered');
assert(rows.children.every(n=>n._prefab?.asset===assets.rankItem),'all rank rows instantiate VipRankItem prefab');
assert(rows.children.every(n=>n.getChildByPath('Spirit630/Spirit631')),'prefab avatar hierarchy is preserved');
const authoredLayout=rows.getComponent(e.Layout);assert(authoredLayout&&rows.getComponent(e.UITransform).height>511,'authored layout grows scroll content');
const viewport=group.getComponentInChildren(e.ScrollView);viewport.scrollToBottom(0);assert(viewport.getScrollOffset().y>0,'authored ScrollView reaches lower rows');viewport.scrollToTop(0);
assert(group.getComponentInChildren(e.ScrollView).content===rows,'native scroll content is bound');
rows.children[1].emit(e.Button.EventType.CLICK);
assert(group.getChildByName('Spirit637').getComponent(e.Label).string==='ID:2','row selection updates detail ID');
assert(group.getChildByName('Spirit641').getComponent(e.Label).string==='个性签名:\n签名2','row selection updates signature');
group.getChildByName('Spirit605').emit(e.Button.EventType.CLICK);assert(panel.modal===null,'rank shade closes');
click(3233);packet('get_treasurelist',{list:[]});assert(panel.modal===null,'empty rank does not open');
click(3233);packet('show_message',{msg:'暂不可用',time:0});assert(!panel.rankPending,'denial releases rank request');
click(3264);assert(panel.modalKind==='cdkey','card entry opens original feedback group');
group=panel.modal.getChildByName('group-74');const edit=group.getChildByName('CardInput').getComponent(e.EditBox);
assert(edit.textLabel&&edit.maxLength===-1,'native card input is bound');
const anchor=edit.textLabel.node.getComponent(e.UITransform).anchorPoint;assert(anchor.x===0&&anchor.y===1,'card label uses native input anchor');
const count=sent.length;edit.string=' ';group.getChildByName('Spirit3266').emit(e.Button.EventType.CLICK);assert(sent.length===count,'blank card is not sent');
group.getChildByName('Spirit3265').emit(e.Button.EventType.CLICK);await Promise.resolve();await Promise.resolve();
assert(edit.string==='00Ab-12','paste preserves original card text');
group.getChildByName('Spirit3266').emit(e.Button.EventType.CLICK);assert(panel.modal===null,'redeem closes original dialog');
assert(sent.at(-1).rpc==='topup_card'&&sent.at(-1).data.cardno==='00Ab-12','redemption sends exact card');
packet('topup_card',{});packet('update_bean',{bean:200});assert(panel.model.bean===200,'server bean update reaches lobby');
const sentBeforeConfig=sent.length;
const cfgRemote=exported('createRemoteConfig')({rankList:1,rankList1:list,nameImgFrame_2:2},config.identity);
panel.config={...config,remoteConfig:cfgRemote};panel.menuConfig=exported('resolveLobbyMenuConfig')(cfgRemote);
click(3233);assert(panel.modalKind==='rank'&&sent.length===sentBeforeConfig,'config rank opens without server request');
assert(panel.modal.getChildByPath('group-84/Spirit639').getComponent(e.Sprite).spriteFrame===assets.rankSymbols[1],'configured currency symbol is used');
await new Promise(resolve=>e.director.once(e.Director.EVENT_AFTER_DRAW,resolve));
globalThis.__rankCardVerification={panel,runtime,checks,close:()=>{off();panel.dispose();runtime.stop();popup.destroy();}};
return {checks,packets:sent.map(p=>({rpc:p.rpc,fields:Object.keys(p.data)}))};
}
@@ -0,0 +1,96 @@
/** Run in Cocos Game View. The actual socket is stopped before all local fixtures. */
export async function verifyRoomEntryPreview(e,loginFixture){
const checks=[],assert=(ok,text)=>{if(!ok)throw Error(text);checks.push(text);};
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const load=id=>new Promise((r,j)=>e.assetManager.loadAny(id,(err,a)=>err?j(err):r(a)));
const exported=name=>Array.from(System.entries()).map(([,m])=>m).find(m=>m[name])?.[name];
const startup=e.director.getScene().getComponentInChildren('PlatformStartup');
assert(startup,'persistent Startup loaded');startup.unsubscribe?.();startup.unsubscribe=null;startup.runtime.stop();startup.panel?.dispose();startup.panel=null;
const canvas=e.director.getScene().getChildByName('Canvas');
let node=null;const tips=[],sent=[];let cancel=0;
const mount=async id=>{if(node){node.destroy();await sleep(50);}node=e.instantiate(await load(id));node.parent=canvas;node.setPosition(0,0,0);const w=node.getComponent(e.Widget);if(w){w.target=canvas;w.updateAlignment();}await sleep(100);return node;};
let pointer=30;
const click=async n=>{const camera=canvas.getComponent(e.Canvas).cameraComponent,p=camera.worldToScreen(n.worldPosition);const touch=new e.Touch(p.x,p.y,pointer++);
e.input._dispatchEventTouch(new e.EventTouch([touch],false,e.Input.EventType.TOUCH_START,[touch]));
e.input._dispatchEventTouch(new e.EventTouch([touch],false,e.Input.EventType.TOUCH_END,[touch]));await sleep(25);};
const keys=['tsgame_roomtype_','tsgame_shortcode_'].map(k=>k+startup.config.identity.gameid+startup.config.identity.agentid);
const old=keys.map(k=>localStorage.getItem(k));
let runtime=null;
try{
await mount('98e42432-0731-4b9e-a158-72032b2c157d');const join=node.getComponent('JoinRoomView');
assert(join&&join.slots.every(s=>!!s.font),'join prefab has wired BitmapFont slots');
join.open({initialCode:'',submit:n=>{sent.push(n);join.setBusy(true);},cancel:()=>cancel++,paste:async()=> '房号123-456789',reportError:t=>tips.push(t)});
for(const d of [0,1,2,3,4,5])await click(join.digitButtons[d].node);
assert(sent.length===1&&sent[0]===12345,'coordinate keypad submits numeric code on sixth digit');
assert(join.slots.map(s=>s.string).join('')==='012345','join slots render all six digits including leading zero');
await click(join.submitButton.node);assert(sent.length===1,'busy submit does not duplicate request');
await click(join.cancelButtons[1].node);assert(cancel===1,'close callback works while busy');
join.setBusy(false);await click(join.deleteButton.node);assert(join.slots.filter(s=>s.node.active).length===5,'delete hides final slot');
await click(join.clearButton.node);assert(join.slots.every(s=>!s.node.active),'clear hides all slots');
await click(join.pasteButton.node);assert(sent[1]===123456,'clipboard strips separators and truncates to six digits');
let finishPaste;const late=[];
join.open({initialCode:'',submit:n=>late.push(n),cancel(){},paste:()=>new Promise(r=>finishPaste=r),reportError:t=>tips.push(t)});
await click(join.pasteButton.node);
const panel=Object.create(exported('LobbyPanel').prototype);panel.roomPage=join;panel.modal=node;panel.modalKind='join';panel.modalReleases=[];
panel.cancelRankRequest=panel.cancelInviteRequest=panel.cancelTaskRequest=()=>{};
panel.cancelRoomPage();node=null;finishPaste('123456');await sleep(40);
assert(late.length===0&&panel.roomPage===null,'disconnect closes room page and invalidates deferred clipboard auto-submit');
await mount('584dbb85-4acc-4bea-9d9b-e0cbeb8741da');const create=node.getComponent('CreateRoomPage');
assert(create&&create.constructor.name==='TemplateCreateRoomView','platform resolves subgame through base page component');
const packets=[];create.open({previousRoomtype:null,submit:r=>{packets.push(r);create.setBusy(true);},cancel:()=>cancel++,reportError:t=>tips.push(t)});
assert(create.options[0].isChecked&&create.options[2].isChecked,'template initial options match source');
await click(create.options[1].node);await click(create.options[3].node);
assert(!create.options[0].isChecked&&create.options[1].isChecked&&!create.options[2].isChecked&&create.options[3].isChecked,'Cocos ToggleContainer enforces each option group');
await click(create.options[1].node);assert(create.options[1].isChecked,'clicking the selected option keeps it selected like the source');
await click(create.submitButton.node);await click(create.submitButton.node);
const roomtype=[1,4,1,2,2,[1,1,[1,2000,10],null,null,1],[1,0,5]];
assert(packets.length===1&&JSON.stringify(packets[0])===JSON.stringify(roomtype),'create page submits source fixed array once');
create.close();create.open({previousRoomtype:roomtype,submit:()=>{},cancel:()=>{},reportError:t=>tips.push(t)});
assert(!create.options[2].isChecked&&!create.options[3].isChecked,'saved template index 4 does not invent a matching option');
node.destroy();node=null;await sleep(50);
// Exercise the real runtime + GameEntry + Startup scene callback with a local wire.
const game=exported('createTemplateGame')(startup.config.identity.gameid),listeners=new Set(),wire={sent:[],subscribe:fn=>{listeners.add(fn);return()=>listeners.delete(fn);},start(){},stop(){},send(p){this.sent.push(p);},switchServer(){},reconnectCurrent(){},emit(ev){for(const fn of [...listeners])fn(ev);}};
startup.game=game;startup.loginResponse=null;let roomPromise=Promise.resolve(),lobbies=0;const fatal=[];
runtime=new (exported('PlatformRuntime'))({gameEntry:game.entry,resolveRuntimeConfig:async()=>startup.config,createWireClient:()=>wire,
scene:{showLoading(){},showLogin(){},showLobby(){lobbies++;},showRoom(){roomPromise=startup.showRoom();},showReconnect(){startup.endRoomRequest();},showTips:t=>tips.push(t),showServerDenial(){startup.endRoomRequest();},showKicked(){},showFatal:error=>fatal.push(String(error))},
loadResources:async()=>{},waitForMinimumDisplay:async()=>{},getLoginDeviceSnapshot:()=>({ip:'127.0.0.1',location:null,machineid:'preview',machineroom:'',deviceLogin:{enabled:false},loginPlayerId:{enabled:false}})});
startup.runtime=runtime;await runtime.start();wire.emit({type:'open'});
runtime.login({openid:'o1',unionid:'test_unionid',nickname:'测试号',avatar:'',sex:0,province:'江西',city:'南昌'});
wire.emit({type:'message',message:{...loginFixture,data:{...loginFixture.data,avatar:''}}});
assert(runtime.state.room.kind==='outside'&&lobbies===1,'local login commits lobby state');
const originalTimer=window.setTimeout;let expire;
try{window.setTimeout=(fn,ms,...args)=>ms===15000?(expire=fn,999999):originalTimer(fn,ms,...args);
startup.requestRoom('create_room',()=>{});
}finally{window.setTimeout=originalTimer;}
const originalTip=startup.tip;startup.tip=t=>tips.push(t);expire();startup.tip=originalTip;
assert(startup.roomPending===null&&tips.pop()==='房间请求超时,请重试','room timeout releases guard and reports retry');
startup.requestRoom('create_room',()=>runtime.createRoom({roomtype,ip:'127.0.0.1',location:null}));
startup.requestRoom('create_room',()=>{throw Error('duplicate request invoked');});
assert(wire.sent.filter(p=>p.rpc==='create_room').length===1,'Startup suppresses duplicate room requests');
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:{state:7,showerror:1,error:'测试拒绝'}}});
assert(runtime.state.room.kind==='outside'&&startup.roomPending===null,'denial releases wait and keeps lobby');
startup.requestRoom('create_room',()=>runtime.createRoom({roomtype,ip:'127.0.0.1',location:null}));
const created={state:0,roomcode:'654321',seat:0,roomtype,makewar:0,asetcount:10};startup.roomReceipt={rpc:'create_room',data:created};
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:created}});await roomPromise;await sleep(100);
assert(fatal.length===0&&runtime.state.room.kind==='inside'&&e.director.getScene().name==='TemplateRoom','create success crosses real Startup scene load');
const roomRoot=e.director.getScene().getChildByName('Canvas').getChildByName('TemplateRoom'),roomView=roomRoot.getComponent('TemplateRoomView');
assert(roomView.roomLabel.string.includes('654321')&&roomView.playersLabel.string.includes('测试号'),'room view consumes module snapshot');
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:created}});await sleep(50);
assert(e.isValid(roomRoot),'duplicate successful reply does not remount room');
roomView.prepareButton.node.emit(e.Button.EventType.CLICK);assert(wire.sent.at(-1).rpc==='player_prepare','subgame prepare travels through GameHost');
wire.emit({type:'message',message:{route:'room',rpc:'player_prepare',data:{seat:0}}});
roomView.exitButton.node.emit(e.Button.EventType.CLICK);assert(wire.sent.at(-1).rpc==='self_exit_room','subgame exit travels through GameHost');
wire.emit({type:'message',message:{route:'room',rpc:'self_exit_room',data:{seat:0}}});
assert(runtime.state.room.kind==='outside'&&lobbies===2&&roomView.actions===null,'exit clears canonical room and disposes view binding');
startup.requestRoom('self_join_room',()=>runtime.joinRoom({roomcode:123456,ip:'127.0.0.1',location:null}));
assert(wire.sent.at(-1).data.roomcode===123456,'manual join preserves numeric wire roomcode');
const players=Array(10).fill(null);players[2]={...loginFixture.data,avatar:'',isprepare:0,onstate:0};
const deskinfo={round:2,cards:[1,2,3]};
wire.emit({type:'message',message:{route:'agent',rpc:'self_join_room',data:{state:0,roomcode:'123456',seat:2,roomtype,players,deskinfo,deskwar:1,needprepare:1,infinite:0}}});
await roomPromise;await sleep(80);
assert(fatal.length===0&&runtime.state.room.selfSeat===2&&game.restoredSnapshot===deskinfo,'join and reconnect snapshot reach template module unchanged');
assert(e.director.getScene().getChildByName('Canvas').getChildByName('TemplateRoom').getComponent('TemplateRoomView').roomLabel.string.includes('123456'),'join success binds its own room scene');
assert(tips.length===0&&fatal.length===0,'no unexpected fixture UI errors');
return {checks,count:checks.length};
}finally{node?.destroy();runtime?.stop();startup.endRoomRequest();keys.forEach((key,i)=>old[i]===null?localStorage.removeItem(key):localStorage.setItem(key,old[i]));}
}