// Real browser DOM/CSS with engine services isolated; this is not a full-game test. const assert = require('node:assert/strict'); const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); const app = path.resolve(__dirname, '../../../..'); const packages = process.env.CODEX_NODE_MODULES || 'C:/Users/Diuse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'; const { chromium } = require(path.join(packages, 'playwright')); const prefix = '/extension/' + encodeURIComponent('术樱包') + '/mengsan'; // Native themes use different button sizes. Drag onto an exposed point on the // original player, rather than assuming its center is clear in the dense fixture. const exposedTarget = (page,index) => page.evaluate(index => { const player=game.players[index],rect=player.getBoundingClientRect(); for(const y of [.5,.15,.85,.05,.95])for(const x of [.5,.15,.85]) { const px=rect.left+rect.width*x,py=rect.top+rect.height*y; if(document.elementFromPoint(px,py)?.closest('.player')===player)return {x:px,y:py,width:0,height:0}; } throw new Error('Native target has no exposed drag point: '+index); },index); const fixture = `
测试技能
结束出牌
`; const server = http.createServer((req, res) => { if (req.url === '/fixture') { res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fixture);return; } if(req.url==='/noname.js'){res.setHeader('Content-Type','text/javascript');res.end('export const _status={};');return;} if(decodeURIComponent(new URL(req.url,'http://localhost').pathname)==='/extension/术樱包/diagnostics/index.js') { res.setHeader('Content-Type','text/javascript');res.end('export const getDiagnostics=()=>({status:()=>({enabled:false}),scope:()=>({info(){},error(){}})});');return; } const target = path.resolve(app, '.' + decodeURIComponent(new URL(req.url, 'http://localhost').pathname)); if (!target.startsWith(app + path.sep)) { res.writeHead(403);res.end();return; } const types = { '.js':'text/javascript', '.css':'text/css', '.png':'image/png' }; fs.readFile(target, (error, bytes) => { if(error){res.writeHead(404);res.end();return;}res.setHeader('Content-Type',types[path.extname(target)]||'text/plain');res.end(bytes); }); }); (async () => { await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)); const origin = 'http://127.0.0.1:' + server.address().port; const browser = await chromium.launch({channel:'msedge',headless:true}); let checks = 0; try { for (const skin of process.env.HUD_TEST_SKIN?[process.env.HUD_TEST_SKIN]:['native','shousha','shizhounian','xinsha']) { for (const viewport of [{width:1280,height:720},{width:1234,height:920},{width:812,height:600},{width:812,height:375},{width:667,height:375},{width:375,height:500},{width:540,height:900}]) { const page = await browser.newPage({viewport,hasTouch:true}); await page.goto(origin+'/fixture'); await page.addStyleTag({content:'body{background:#161e29 url("/image/background/lanting_bg.jpg") center/cover no-repeat}'}); await page.addStyleTag({url:origin+prefix+'/ui/style.css'}); if(skin!=='native') { for(const css of ['/extension/'+encodeURIComponent('十周年UI')+'/src/styles/layout.css','/extension/'+encodeURIComponent('十周年UI')+'/ui/styles/base.css','/extension/'+encodeURIComponent('十周年UI')+'/ui/styles/skill/'+skin+'.css','/extension/'+encodeURIComponent('十周年UI')+'/ui/styles/lbtn/'+skin+'.css'])await page.addStyleTag({url:origin+css}); await page.addStyleTag({url:origin+'/extension/'+encodeURIComponent('十周年UI')+'/src/styles/player'+({shousha:2,shizhounian:1,xinsha:3}[skin])+'.css'}); await page.addStyleTag({url:origin+'/extension/'+encodeURIComponent('十周年UI')+'/src/styles/equip.css'}); } await page.addStyleTag({url:origin+prefix+'/ui/hand/style.css'}); const initial = await page.evaluate(async ({prefix,skin}) => { const mod = await import(prefix+'/ui/hud/index.js').catch(error=>({error:String(error)})); if(typeof mod.mountBattleHUD!=='function')return {module:false,error:mod.error}; const releases=[];window.session={id:'hud-test',active:true,ownResource:(_,f)=>releases.push(f)}; window.releaseAll=()=>releases.reverse().forEach(f=>f()); const arena=document.getElementById('arena'), hand=document.getElementById('me'), control=document.getElementById('control'); if(skin!=='native') { // Match create.js and player-element.js, including the renamed container and nested wraps. control.id='dui-controls';hand.classList.add('hand-wrap');arena.classList.add('decadeUI'); arena.dataset.mode='identity';arena.dataset.outcropSkin='shizhounian';arena.dataset.playerHeight='default'; if(window.innerHeight<=500||window.innerWidth<=600)arena.classList.add('dui-mobile'); control.firstElementChild.classList.add('stayleft'); } document.body.classList.add('mengsan-hand-ui-shuying');control.firstElementChild.style.opacity='1'; [...control.firstElementChild.children].forEach((node,index)=>{ node.classList.add(index?'primary2':'primary'); const label=index?'回合结束':'确定'; if(skin==='native')node.textContent=label;else {const span=document.createElement('span');span.textContent=label;node.replaceChildren(span);} node.addEventListener('click',()=>{window.nativeActionClicks=(window.nativeActionClicks||0)+1;}); }); const players=[]; for(let i=0;i<8;i++) { const player=document.createElement('div');player.className='player';player.dataset.position=String(i);player.playerid=String(i);player.name=i?'enemy'+i:'liubei';player.hp=40;player.maxHp=60;player.hujia=8; player.storage={mengsanCamp_shuying:i?'enemy':'ally',mengsanStrength_shuying:i===1?3:0,mengsanVulnerable_shuying:i===2?2:0,mengsanEnergy_shuying:3,mengsanMaxEnergy_shuying:3}; player.innerHTML=skin==='native' ? '
'+(i?'敌人'+i:'刘备')+'
40/60
' : '
回合开始
汉
'+(i?'敌人'+i:'刘备')+'
40/60
8
'; if(i)player.insertAdjacentHTML('beforeend','
攻击 8×2
'); player.node={avatar:player.querySelector('.avatar,.primary-avatar'),hp:player.querySelector('.hp'),hpWrap:player.querySelector('.hp-wrap'),campWrap:player.querySelector('.camp-wrap'),name:player.querySelector('.name,.avatar-name'),identity:player.querySelector('.identity'),framebg:player.querySelector('.framebg'),count:player.querySelector('.card-count'),marks:player.querySelector('.marks,.dui-marks')}; player.node.avatar.style.backgroundImage='url("/image/character/'+(i?'caocao':'liubei')+'.jpg")'; if(i){player.style.cssText=`left:${(i-1)%4*21+5}%;top:${68+Math.floor((i-1)/4)*96}px;width:68px;height:82px;transform:none`;} const equips=document.createElement('div');equips.className='equips';player.node.equips=equips;player.appendChild(equips); if(i===0||i===1)for(let j=0;j<(i===0?5:2);j++){ const equip=document.createElement('div');equip.className='card equip'+(j+1);equip.innerHTML='
♠2 '+(['八卦阵','的卢','青釭剑','赤兔','木牛'][j])+'
'; equip.addEventListener('click',e=>{e.stopPropagation();window.equipClicks=(window.equipClicks||0)+1;});equips.appendChild(equip); } player.getCards=zone=>zone==='e'?[...equips.children]:[]; player.marks={};player.hasSkill=()=>false;player.getSkills=()=>[];player.isAlive=()=>player.hp>0;arena.appendChild(player);players.push(player); } const me=players[0];window.game={me,players,dead:[]};window.battle={session,players:new Set(players),owner:me}; const nativeMark=document.createElement('div');nativeMark.className='mark';nativeMark.textContent='义';me.node.marks.appendChild(nativeMark);window.testNativeMark=nativeMark; nativeMark.addEventListener('click',event=>{ event.stopPropagation();window.markIntroOpens=(window.markIntroOpens||0)+1; const box=me.getBoundingClientRect(),tip=document.createElement('div');tip.className='dialog popped static'; tip.style.cssText=`position:absolute;left:${box.right-20}px;top:${box.top-30}px;width:236px;height:160px;padding:16px;z-index:21`; tip.innerHTML='
桃园羁绊
先天结识关羽、张飞。保留原生标记说明内容与关闭回调。
'; const layer=document.createElement('div');layer.className='poplayer';layer.style.cssText='position:absolute;inset:0'; const close=()=>{tip.remove();layer.remove();window.markIntroClosed=true;};layer.addEventListener('click',close);tip._close=close; ui.window.append(tip,layer);window.testMarkIntro=tip; }); window.hudEventState={currentPhase:me,event:{name:'chooseToUse',player:me,isMine:()=>true}}; window.ui={arena,me:hand,mebg:document.getElementById('mebg'),control,window:document.getElementById('window'),handcards1Container:document.getElementById('handcards1'),handcards2Container:document.getElementById('handcards2'),selected:{cards:[],targets:[],buttons:[]},click:{}}; if(skin==='xinsha') { const host=document.createElement('div');host.className='equips-wrap';host.style.cssText='position:absolute;left:150px;top:0;width:130px;height:100%'; host.innerHTML='
装备槽
';arena.insertBefore(host,hand);host.appendChild(me.node.equips);ui.equipSolts=host; } window.originalEquipParent=me.node.equips.parentNode; me.node.handcards1=ui.handcards1Container.firstElementChild;me.node.handcards2=ui.handcards2Container.firstElementChild; const get={translation:value=>value==='liubei'?'刘备':String(value).replace(/^enemy/,'敌人'),select:value=>value}; const card=document.createElement('div');card.className='card selectable';card.textContent='杀';card.name='sha';me.node.handcards1.appendChild(card);window.card=card; card.addEventListener('contextmenu',event=>{ event.preventDefault();window.introOpens=(window.introOpens||0)+1; const tip=document.createElement('div');tip.className='dialog popped static'; const rect=card.getBoundingClientRect();tip.style.cssText=`position:absolute;left:${rect.left}px;top:${rect.top-30}px;width:236px;height:160px;z-index:21;padding:16px`; tip.innerHTML='
杀 · 卡牌说明
对选定敌人造成伤害。说明保留原生内容与关闭操作。
'; const layer=document.createElement('div');layer.className='poplayer';layer.style.cssText='position:absolute;inset:0'; layer.addEventListener('click',()=>{tip.remove();layer.remove();window.nativeIntroClosed=true;}); ui.window.append(tip,layer);window.testIntro=tip; }); window.oldParents={hand:hand.parentNode,control:control.parentNode,card:card.parentNode}; window.nativeActionGroup=control.firstElementChild;window.nativeActionButtons=[...nativeActionGroup.children]; window.actionPaint=node=>{const s=getComputedStyle(node);return Object.fromEntries(['backgroundImage','backgroundColor','borderImageSource','borderTopColor','borderTopStyle','borderTopWidth','borderTopLeftRadius','color','fontFamily','fontSize','fontWeight','textShadow','boxShadow','filter','paddingTop','paddingRight','paddingBottom','paddingLeft'].map(key=>[key,s[key]]));}; // Decade animates the initial class/theme change; compare settled native styles. await new Promise(resolve=>setTimeout(resolve,650)); window.originalActionPaint=[nativeActionGroup,...nativeActionButtons].map(actionPaint); window.originalActionHTML=nativeActionGroup.innerHTML; window.originalEnemyPresentation=players.slice(1).map(p=>({style:p.style.cssText,classes:p.className,html:p.innerHTML})); window.hud=mod.mountBattleHUD(me,battle,{ui,game,get,lib:{skill:{},translate:{}},_status:hudEventState,document,window,assetBase:prefix}); const {mountRelicStrip}=await import(prefix+'/ui/relic-strip.js'); window.testRelics=[];window.testStrip=mountRelicStrip(arena,()=>testRelics,relic=>{window.lastRelicOpened=relic.id;},()=>{ const icon=document.createElement('img');icon.src=prefix+'/assets/status/strength_power.png';return icon; },document,window);releases.push(()=>testStrip.dispose()); let submitted=0;game.check=()=>ui.selected.cards.length===1&&ui.selected.targets.length===1;game.uncheck=()=>{ui.selected.cards=[];ui.selected.targets=[];}; ui.click.card=function(){ui.selected.cards=[this];players.slice(1).forEach(p=>p.classList.add('selectable'));};ui.click.target=function(){ui.selected.targets=[this];};ui.click.ok=()=>{submitted++;game.uncheck();}; game.isSubmitted=()=>submitted;const event=hudEventState.event;event.selectTarget=[1,1];event.selectCard=[1,1]; const {mountHandUI}=await import(prefix+'/ui/hand/index.js'); window.testHandUI=await mountHandUI(me,session,{ui,game,get,lib:{card:{},translate:{}},_status:hudEventState,document,window,isActiveCardUse:()=>true,canPayCard:()=>true,cardCost:()=>1,styleURL:prefix+'/ui/hand/style.css'}); window.testInput={owns:()=>false};hud.refresh();window.testFan={sync:()=>testHandUI.refresh()}; return {module:true,parentKept:hand.parentNode===oldParents.hand&&control.parentNode===oldParents.control&&card.parentNode===oldParents.card,skin}; },{prefix,skin}); assert.equal(initial.module,true,initial.error||'HUD mount module must exist');checks++; assert.equal(initial.parentKept,true);checks++; await page.waitForTimeout(70); await page.evaluate(()=>testFan.sync(true)); const corners=await page.evaluate(()=>({portrait:parseFloat(getComputedStyle(game.me.node.avatar).borderTopLeftRadius)})); assert.ok(corners.portrait>=12,'portrait retains rounded edges: '+JSON.stringify(corners));checks++; const glass=await page.locator('.mengsan-hud-shuying').evaluate(n=>{const s=getComputedStyle(n);return {background:s.backgroundColor,opacity:s.opacity,blur:s.backdropFilter};}); assert.equal(glass.background,'rgba(0, 0, 0, 0)','footer is completely transparent');checks++; assert.equal(glass.blur,'none','footer has no background blur');checks++; assert.equal(glass.opacity,'1','text and buttons retain full opacity');checks++; const equipLayout=await page.evaluate(()=>{ const p=game.me.getBoundingClientRect(),e=game.me.node.equips.getBoundingClientRect(),enemy=game.players[1],p2=enemy.getBoundingClientRect(),e2=enemy.node.equips.getBoundingClientRect(); return {self:p.toJSON(),selfEquips:e.toJSON(),enemy:p2.toJSON(),enemyEquips:e2.toJSON()}; }); assert.ok(equipLayout.selfEquips.top>=equipLayout.self.top&&equipLayout.selfEquips.bottom<=equipLayout.self.bottom&&equipLayout.selfEquips.left>=equipLayout.self.left&&equipLayout.selfEquips.right<=equipLayout.self.right,'equipment overlays the enlarged portrait: '+JSON.stringify(equipLayout));checks++; assert.ok(equipLayout.selfEquips.bottom<=viewport.height-8,'equipment stays inside the player portrait');checks++; assert.equal(await page.evaluate(()=>getComputedStyle(game.me.node.equips).flexDirection),'column','equipment is stacked vertically over the portrait');checks++; try { await page.locator('.mengsan-hud-self-equips-shuying>.card').first().click({timeout:3000}); } catch(error) { console.log(await page.evaluate(()=>[game.me.node.equips.firstElementChild,game.me.node.equips,game.me.node.equips.parentNode,ui.arena].map(n=>({cls:n.className,rect:n.getBoundingClientRect().toJSON(),style:n.style.cssText,overflow:getComputedStyle(n).overflow,scroll:[n.scrollLeft,n.scrollTop]}))));throw error; } assert.equal(await page.evaluate(()=>window.equipClicks),1,'real equipment callback is preserved');checks++; assert.equal(await page.evaluate(()=>game.me.node.equips.parentNode===originalEquipParent),true,'optional separate equipment wrapper keeps its real parent');checks++; await page.evaluate(()=>{const cards=game.me.node.equips.children;cards[4].classList.add('selectable');cards[4].scrollIntoView({block:'nearest',inline:'nearest'});}); await page.locator('.mengsan-hud-self-equips-shuying>.card').last().click(); assert.equal(await page.evaluate(()=>window.equipClicks),2,'last of five equipped cards remains reachable');checks++; const layout=await page.evaluate(()=>{ const rect=n=>{const r=n.getBoundingClientRect();return {x:r.x,y:r.y,right:r.right,bottom:r.bottom,width:r.width,height:r.height};}; return {hud:rect(document.querySelector('.mengsan-hud-shuying')),hand:rect(ui.me),control:rect(ui.control),players:game.players.slice(1).map(rect),statusCount:document.querySelectorAll('.mengsan-unit-status-shuying').length, statusRow:rect(document.querySelector('.mengsan-hud-statuses-shuying'))}; }); assert.equal(layout.statusCount,0,'enemy information is not replaced by the player HUD');checks++; assert.ok(layout.control.bottom<=layout.hand.y-7,'horizontal actions sit above the hand area');checks++; assert.equal(await page.evaluate(()=>game.players.slice(1).every((p,i)=>p.style.cssText===originalEnemyPresentation[i].style&&p.innerHTML===originalEnemyPresentation[i].html&&p.className===originalEnemyPresentation[i].classes)),true,'enemy and ally DOM, inline positions and classes stay native');checks++; assert.equal(await page.evaluate(()=>{const p=game.me.getBoundingClientRect(),s=document.querySelector('.mengsan-hud-info-shuying').getBoundingClientRect();return p.right<=s.left||p.bottom<=s.top||p.left>=s.right||p.top>=s.bottom;}),true,'enlarged portrait clears the vertical statistics');checks++; assert.equal(await page.evaluate(()=>{const p=game.me.getBoundingClientRect(),s=document.querySelector('.mengsan-hud-info-shuying').getBoundingClientRect();return ui.arena.dataset.mengsanHudLayout==='narrow'?p.bottom<=s.top&&s.top-p.bottom<32:s.left-p.right>=0&&s.left-p.right<=16;}),true,'statistics stay close to the portrait');checks++; assert.equal(await page.locator('.mengsan-hud-center-shuying').isVisible(),false,'empty status area is hidden');checks++; assert.equal(await page.locator('.mengsan-hud-shuying').innerText().then(t=>/当前状态|暂无状态|选择行动|等待行动/.test(t)),false,'status headings and action filler text are absent');checks++; assert.deepEqual(await page.evaluate(()=>[nativeActionGroup,...nativeActionButtons].map(actionPaint)),await page.evaluate(()=>originalActionPaint),'native action group and button paint, typography and padding stay unchanged');checks++; assert.equal(await page.evaluate(()=>ui.control.firstElementChild===nativeActionGroup&&nativeActionGroup.innerHTML===originalActionHTML&&nativeActionButtons.every((node,i)=>nativeActionGroup.children[i]===node)),true,'real native action nodes and contents are retained');checks++; assert.equal(await page.locator('.mengsan-hud-inspect-shuying').count(),0,'skills and equipment proxy button is removed');checks++; await page.locator('.mengsan-hud-controls-shuying>.control>div').first().click(); await page.locator('.mengsan-hud-controls-shuying>.control>div').nth(1).click(); assert.equal(await page.evaluate(()=>nativeActionClicks),2,'both real native confirm and end-turn callbacks remain usable');checks++; assert.equal(await page.evaluate(()=>nativeActionButtons.every(n=>n.getBoundingClientRect().bottom<=ui.me.getBoundingClientRect().top-4)),true,'native button dimensions remain fully above the hand');checks++; await page.evaluate(()=>{for(let i=0;i<6;i++)testNativeMark.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));}); await page.waitForTimeout(60); await page.evaluate(()=>{for(let i=0;i<3;i++)testNativeMark.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));}); const markIntro=await page.evaluate(()=>({opens:markIntroOpens,count:ui.window.querySelectorAll('.dialog.popped').length,layers:ui.window.querySelectorAll('.poplayer').length,layerClass:ui.window.querySelector('.poplayer').className,layerStyle:ui.window.querySelector('.poplayer').style.cssText,box:testMarkIntro.getBoundingClientRect().toJSON(),portrait:game.me.getBoundingClientRect().toJSON(),info:document.querySelector('.mengsan-hud-info-shuying').getBoundingClientRect().toJSON(),z:getComputedStyle(testMarkIntro).zIndex,layerZ:getComputedStyle(ui.window.querySelector('.poplayer')).zIndex})); assert.ok(markIntro.opens===1&&markIntro.count===1&&markIntro.layers===1,'rapid mark clicks keep one native popup and one backdrop: '+JSON.stringify(markIntro));checks++; assert.ok(markIntro.box.bottom<=Math.min(markIntro.portrait.top,markIntro.info.top)&&markIntro.box.x>=8&&markIntro.box.right<=viewport.width-8,'mark details clear the player portrait and statistics: '+JSON.stringify(markIntro));checks++; assert.ok(Number(markIntro.z)>Number(markIntro.layerZ)&&Number(markIntro.layerZ)>31,'native mark popup and backdrop draw above player and skills: '+JSON.stringify(markIntro));checks++; if(viewport.width===1234){await page.waitForTimeout(350);await page.screenshot({path:path.join(process.env.HUD_ARTIFACTS||__dirname,'hud-mark-description-'+skin+'.png')});} await page.locator('.poplayer').click({position:{x:4,y:4}}); assert.equal(await page.evaluate(()=>markIntroClosed),true,'native mark close callback is preserved');checks++; await page.evaluate(()=>testNativeMark.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}))); await page.waitForTimeout(30); assert.equal(await page.evaluate(()=>markIntroOpens),2,'mark details can reopen after closing');checks++; await page.locator('.poplayer').click({position:{x:4,y:4}}); assert.equal(await page.locator('.mengsan-hand-relics-shuying').isVisible(),false,'empty relic strip stays hidden');checks++; const nativeSkills=await page.evaluate(()=>{ const skills=document.createElement('div');skills.className='control';skills.innerHTML='
原生技能甲
原生技能乙
'; ui.control.appendChild(skills);ui.skills=skills;window.testNativeSkills=skills; skills.firstElementChild.addEventListener('click',()=>window.nativeSkillUsed=true);hud.refresh(); const boxes=[...skills.children].map(n=>n.getBoundingClientRect()); const rail=skills.getBoundingClientRect();return {vertical:boxes[0].bottom<=boxes[1].top,right:rail.left>=ui.me.getBoundingClientRect().right,contained:boxes.every(r=>r.left>=rail.left-1&&r.right<=rail.right+1)}; }); assert.ok(nativeSkills.vertical&&nativeSkills.right&&nativeSkills.contained,'native skill references stay vertically on the right without moving their parent or clipping buttons: '+JSON.stringify(nativeSkills));checks++; await page.getByText('原生技能甲',{exact:true}).click(); assert.equal(await page.evaluate(()=>window.nativeSkillUsed),true,'native skill callbacks survive the separate rail');checks++; await page.evaluate(()=>{testNativeSkills.remove();delete ui.skills;hud.refresh();}); const relicGeometry=await page.evaluate(()=>{ testRelics=[{id:'test',name:'遗物'}];testStrip.refresh(); const strip=document.querySelector('.mengsan-hand-relics-shuying').getBoundingClientRect(),icon=document.querySelector('.mengsan-hand-relics-shuying button').getBoundingClientRect(); return {strip:strip.toJSON(),icon:icon.toJSON()}; }); assert.ok(relicGeometry.icon.y>=relicGeometry.strip.y&&relicGeometry.icon.bottom<=relicGeometry.strip.bottom,'top relic icon fits without clipping');checks++; assert.ok(relicGeometry.strip.width<=142,'relic area stays compact above the right skill rail');checks++; assert.ok(relicGeometry.strip.x>=viewport.width-166,'relic area is on the right');checks++; assert.ok(Math.abs(relicGeometry.strip.bottom-(viewport.height-Number.parseFloat(await page.locator('#arena').evaluate(n=>n.style.getPropertyValue('--mengsan-relic-dock-bottom')))))<2,'relic area aligns with the side dock, with a compact narrow-screen offset');checks++; await page.evaluate(()=>{testRelics=Array.from({length:10},(_,i)=>({id:'r'+i,name:'遗物'+i}));testStrip.refresh();hud.refresh();}); const battlefieldClear=await page.evaluate(()=>{ const strip=document.querySelector('.mengsan-hand-relics-shuying').getBoundingClientRect(); const intersects=(a,b)=>a.leftb.left&&a.topb.top; return {clear:[...document.querySelectorAll('.player>.mengsan-intent-shuying')].every(n=>!intersects(n.getBoundingClientRect(),strip)),strip:strip.toJSON(),intents:[...document.querySelectorAll('.player>.mengsan-intent-shuying')].map(n=>n.getBoundingClientRect().toJSON())}; }); assert.equal(battlefieldClear.clear,true,'a full relic strip must not cover enemy intent: '+JSON.stringify(battlefieldClear));checks++; await page.locator('.mengsan-hand-relics-shuying button').last().focus(); await page.locator('.mengsan-hand-relics-shuying button').last().press('Enter'); assert.equal(await page.evaluate(()=>lastRelicOpened),'r9','overflow relics retain keyboard access to details');checks++; await page.evaluate(()=>{testRelics=[{id:'test',name:'遗物'}];testStrip.refresh();}); const start=await page.locator('.handcards>.card').boundingBox(),target=await exposedTarget(page,3); await page.mouse.move(start.x+start.width/2,start.y+30);await page.mouse.down(); await page.mouse.move(target.x+target.width/2,target.y+target.height/2,{steps:8});await page.mouse.up(); assert.equal(await page.evaluate(()=>game.isSubmitted()),1,'single target drag submits once');checks++; if(skin!=='native') { await page.evaluate(()=>{ const skills=document.createElement('div');skills.className='skill-control';skills.innerHTML='
主动技能
';ui.arena.appendChild(skills);ui.skillControl=skills; skills.querySelector('.skillitem').addEventListener('click',()=>{hudEventState.event.skill='test-skill';});hud.refresh(); }); await page.locator('.skillitem').click(); assert.equal(await page.evaluate(()=>hudEventState.event.skill),'test-skill');checks++; assert.notEqual(await page.locator('.skillitem').evaluate(n=>getComputedStyle(n).webkitTextFillColor),'rgba(0, 0, 0, 0)','skin gradient text remains readable after reskinning');checks++; assert.equal(await page.evaluate(()=>ui.skillControl.getBoundingClientRect().left>=ui.me.getBoundingClientRect().right),true,'vertical skills stay to the right of the hand');checks++; await page.evaluate(()=>{ const globalSkills=document.createElement('div');globalSkills.className='gskill-control';globalSkills.innerHTML='
仁德
'; ui.arena.appendChild(globalSkills);ui.gskillControl=globalSkills;hud.refresh(); }); const rails=await page.evaluate(()=>({skills:ui.skillControl.getBoundingClientRect().toJSON(),global:ui.gskillControl.getBoundingClientRect().toJSON(),native:ui.control.getBoundingClientRect().toJSON(),hud:document.querySelector('.mengsan-hud-shuying').getBoundingClientRect().toJSON()})); assert.ok(rails.global.bottom<=rails.skills.top-4&&rails.global.top>=rails.hud.top,'optional global skill controls stay inside the HUD without overlap: '+JSON.stringify(rails));checks++; await page.evaluate(()=>{delete hudEventState.event.skill;}); assert.equal(await page.evaluate(()=>ui.arena.scrollLeft===0&&ui.arena.scrollTop===0),true,'skill controls must not scroll the arena');checks++; } const beforeTouch = await page.evaluate(()=>game.isSubmitted()); const touchCard = await page.locator('.handcards>.card').boundingBox(); await page.evaluate(({start,target})=>{ const send=(host,type,x,y)=>host.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,pointerId:17,pointerType:'touch',isPrimary:true,button:0,clientX:x,clientY:y})); const x=start.x+start.width/2,y=start.y+25,tx=target.x+target.width/2,ty=target.y+target.height/2; send(card,'pointerdown',x,y);send(window,'pointermove',tx,ty);send(window,'pointerup',tx,ty); },{start:touchCard,target:await exposedTarget(page,2)}); const afterTouch=await page.evaluate(()=>({submitted:game.isSubmitted(),selected:ui.selected.cards.length,targets:ui.selected.targets.length,eventSkill:hudEventState.event.skill,classes:card.className,owns:testInput.owns(),hand:ui.me.getBoundingClientRect().toJSON(),card:card.getBoundingClientRect().toJSON()})); if(afterTouch.submitted!==beforeTouch+1){afterTouch.geometry=await page.evaluate(()=>['window','arena','me','control'].map(id=>{const n=id==='control'?ui.control:document.getElementById(id),s=getComputedStyle(n);return {id,rect:n.getBoundingClientRect().toJSON(),css:{transform:s.transform,zoom:s.zoom,left:s.left,top:s.top,width:s.width,height:s.height,translate:s.translate},inline:n.style.cssText};}));await page.screenshot({path:path.join(process.env.HUD_ARTIFACTS||__dirname,'hud-failure.png')});} assert.equal(afterTouch.submitted,beforeTouch+1,'touch pointer drag submits once: '+JSON.stringify(afterTouch));checks++; await page.evaluate(()=>{ const group=document.createElement('div');group.className='control';group.style.opacity='1'; const button=document.createElement('div');button.textContent='动态技能';button.addEventListener('click',()=>{window.dynamicSkillUsed=true;});group.appendChild(button);ui.control.replaceChildren(group);hud.refresh(); }); await page.getByText('动态技能',{exact:true}).click(); assert.equal(await page.evaluate(()=>window.dynamicSkillUsed),true,'new native controls retain their callbacks');checks++; await page.evaluate(()=>{game.players[7].hp=0;hud.refresh();}); assert.equal(await page.evaluate(()=>game.players[7].style.cssText===originalEnemyPresentation[6].style&&!game.players[7].classList.contains('mengsan-hud-defeated-shuying')),true,'death keeps the original battlefield presentation');checks++; await page.evaluate(()=>{game.players[7].hp=40;game.me.storage.mengsanVulnerable_shuying=3;hud.refresh();}); assert.match(await page.locator('.mengsan-hud-statuses-shuying').innerText(),/3/);checks++; await page.evaluate(()=>{for(const key of ['Weak','Frail','Artifact','Constrict','Tangled','Shrink','Plow','Slippery'])game.me.storage['mengsan'+key+'_shuying']=2;hud.refresh();}); assert.equal(await page.evaluate(()=>{const row=document.querySelector('.mengsan-hud-statuses-shuying').getBoundingClientRect();return [...document.querySelectorAll('.mengsan-hud-statuses-shuying button')].every(n=>{const r=n.getBoundingClientRect();return r.bottom<=row.bottom+1&&r.right<=row.right+1;});}),true,'matrix overflow keeps the visible statuses and more button inside the grid');checks++; await page.getByRole('button',{name:'查看全部状态',exact:true}).click(); assert.ok(await page.getByRole('dialog').isVisible(),'overflow states remain available in the details');checks++; await page.getByRole('button',{name:'关闭',exact:true}).click(); assert.equal(await page.evaluate(()=>{const s=document.querySelector('.mengsan-hud-center-shuying').getBoundingClientRect(),p=game.me.getBoundingClientRect();return s.bottom<=p.top&&p.top-s.bottom<=13;}),true,'status chips automatically appear just above the portrait');checks++; await page.locator('.mengsan-hud-statuses-shuying button').first().click(); assert.ok(await page.locator('dialog.mengsan-hud-dialog-shuying').isVisible());checks++; await page.getByRole('button',{name:'关闭',exact:true}).click(); await page.locator('.mengsan-hud-statuses-shuying button').first().tap(); assert.ok(await page.locator('dialog.mengsan-hud-dialog-shuying').isVisible(),'touch opens the player status details');checks++; await page.getByRole('button',{name:'关闭',exact:true}).click(); await page.evaluate(()=>game.uncheck()); const prompt=await page.evaluate(()=>{ const n=document.createElement('div');n.className='dialog';n.innerHTML='
请弃置两张手牌
';ui.arena.appendChild(n);ui.dialog=n;hudEventState.event.dialog=n;window.testPrompt=n;hud.refresh(); return {r:n.getBoundingClientRect().toJSON(),control:ui.control.getBoundingClientRect().toJSON()}; }); assert.ok(prompt.r.bottom<=prompt.control.top-5,'native selection prompt stays above the horizontal action row');checks++; await page.locator('.handcards>.card').first().click({button:'right'}); await page.waitForTimeout(80); const intro=await page.evaluate(()=>({opened:introOpens,box:testIntro.getBoundingClientRect().toJSON(),content:testIntro.querySelector('.content').getBoundingClientRect().toJSON(),hand:ui.me.getBoundingClientRect().toJSON(),prompt:testPrompt.getBoundingClientRect().toJSON(),z:Number(getComputedStyle(testIntro).zIndex),scroll:getComputedStyle(testIntro).overflowY})); assert.equal(intro.opened,1,'right click still invokes the original card introduction');checks++; assert.ok(intro.box.height>=40,'native absolute content wrapper does not collapse the description');checks++; assert.ok(intro.content.height>20&&intro.content.top>=intro.box.top&&intro.content.top=8&&intro.box.right<=viewport.width-8,'description stays above hand and prompt, inside the viewport: '+JSON.stringify(intro));checks++; assert.ok(intro.z>24&&intro.scroll==='auto','description draws above cards and scrolls when height is limited');checks++; if(viewport.width===1234){await page.waitForTimeout(350);await page.screenshot({path:path.join(process.env.HUD_ARTIFACTS||__dirname,'hud-card-description-'+skin+'.png')});} await page.locator('.poplayer').click({position:{x:4,y:4}}); assert.equal(await page.evaluate(()=>window.nativeIntroClosed),true,'original popup backdrop still closes the description');checks++; await page.evaluate(()=>{testPrompt.remove();delete ui.dialog;delete hudEventState.event.dialog;hud.refresh();}); if(viewport.width===1234||skin==='native'&&viewport.width===812) { await page.evaluate(()=>{ for(let i=0;i<6;i++) { const c=i===0?card:card.cloneNode(true);c.textContent=['杀','八卦阵','杀','桃','杀','无懈可击'][i];c.style.background='#e9dfc9 url("/image/card/'+(['sha','bagua','sha','tao','sha','wuxie'][i])+'.png") center/100% 100%'; if(i)game.me.node.handcards1.appendChild(c); } if(ui.arena.clientWidth>1000){game.players.slice(4).forEach(p=>{p.hp=0;p.remove();battle.players.delete(p);});delete game.me.storage.mengsanVulnerable_shuying;} testFan.sync(true);hud.refresh(); }); await page.waitForTimeout(180); const fullCards=await page.locator('.handcards>.card').evaluateAll(nodes=>nodes.map(n=>n.getBoundingClientRect().toJSON())); assert.ok(fullCards.every(r=>r.bottom<=viewport.height-8),'rotated hand cards remain fully above the bottom edge: '+JSON.stringify(fullCards));checks++; await page.screenshot({path:path.join(process.env.HUD_ARTIFACTS||__dirname,'hud-transparent-'+skin+'-'+viewport.width+'x'+viewport.height+'.png')}); } const popup=await page.evaluate(()=>{ const n=document.createElement('div');n.className='damage skill-popup';n.style.cssText=`position:absolute;left:${game.me.offsetLeft}px;top:${game.me.offsetTop+80}px;width:${game.me.offsetWidth}px;z-index:9999`; for(const char of '回合开始'){const s=document.createElement('span');s.textContent=char;n.appendChild(s);}window.testPopup=n;window.testPopupTop=n.style.top;ui.arena.appendChild(n);hud.refresh(); const rect=n.getBoundingClientRect(),footer=document.querySelector('.mengsan-hud-shuying').getBoundingClientRect(); return {bottom:rect.bottom,footer:footer.top}; }); assert.ok(popup.bottom<=popup.footer-4,'skin notification stays above the command footer: '+JSON.stringify(popup));checks++; await page.evaluate(()=>{testHandUI.freeze();hud.freeze();session.active=false;testHandUI.refresh();hud.refresh();}); await page.waitForTimeout(240); assert.equal(await page.evaluate(()=>document.querySelector('.mengsan-hud-shuying')!==null&&game.me.classList.contains('mengsan-hud-self-shuying')&&game.me.node.handcards1.classList.contains('mengsan-hand-layout-shuying')),true,'stopped battle retains player HUD and fan until reward cleanup');checks++; await page.evaluate(()=>{hud.dispose();hud.dispose();}); assert.equal(await page.evaluate(()=>game.me.node.equips.parentNode===originalEquipParent&&!game.me.node.equips.classList.contains('mengsan-hud-equips-shuying')&&!game.me.node.equips.style.getPropertyValue('top')),true,'equipment ownership and styles restore');checks++; assert.equal(await page.evaluate(()=>!testPopup.classList.contains('mengsan-hud-notification-shuying')&&testPopup.style.top===testPopupTop),true,'notification placement is restored when HUD ends');checks++; assert.equal(await page.locator('.mengsan-hud-shuying').count(),0);checks++; assert.equal(await page.evaluate(()=>ui.me.parentNode===oldParents.hand&&ui.control.parentNode===oldParents.control&&!document.body.classList.contains('mengsan-hud-active-shuying')),true);checks++; assert.equal(await page.evaluate(()=>game.players.slice(1).every((p,i)=>p.style.cssText===originalEnemyPresentation[i].style)),true,'native actor styles survive final cleanup');checks++; await page.evaluate(()=>releaseAll()); console.log(skin+' '+viewport.width+'x'+viewport.height+' passed'); await page.close(); } } console.log('HUD browser checks: '+checks); } finally {await browser.close();await new Promise(resolve=>server.close(resolve));} })().catch(error=>{console.error(error);server.close();process.exitCode=1;});