Files
noname-shuying/mengsan/tools/test-battle-hud.cjs
T
2026-10-23 00:38:05 +08:00

337 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = `<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/layout/default/layout.css"><div id="window"><div id="arena"><div id="mebg"></div><div id="me" class="mengsan-hand-root-shuying"><div id="handcards1" class="mengsan-hand-scroll-shuying"><div class="handcards mengsan-hand-zone-shuying mengsan-hand-layout-shuying"></div></div><div id="handcards2"><div class="handcards"></div></div></div><div id="control"><div class="control"><div>测试技能</div><div>结束出牌</div></div></div></div></div><style>html,body,#window{margin:0;width:100%;height:100%;position:absolute}#arena{inset:0;width:100%;height:100%;position:absolute}#arena>.player{position:absolute;width:100px;height:130px;background:#345;color:#fff}#arena>.player .avatar{height:100%;width:100%;background:linear-gradient(#334f4b,#172d29)}#arena>.player .name{position:absolute;top:4px;left:4px}#me{position:absolute;bottom:0;width:100%;height:160px}#handcards1,#handcards2{position:absolute;width:100%;height:100%}.handcards{height:100%}.card{background:#e8d7b2;color:#263b35;position:absolute}.mengsan-intent-shuying{position:absolute}</style>`;
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' ? '<div class="avatar"></div><div class="name">'+(i?'敌人'+i:'刘备')+'</div><div class="hp">40/60</div><div class="marks"></div>' : '<div class="primary-avatar has-outcrop"><div class="action">回合开始</div></div><div class="framebg"></div><div class="identity">汉</div><div class="camp-wrap"><div class="camp-border"></div><div class="avatar-name">'+(i?'敌人'+i:'刘备')+'</div></div><div class="hp-wrap"><div class="hp">40/60</div></div><div class="card-count">8</div><div class="dui-marks"></div>';
if(i)player.insertAdjacentHTML('beforeend','<div class="mengsan-intent-shuying">攻击 8×2</div>');
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='<div class="name2"><span>♠2 </span><span>'+(['八卦阵','的卢','青釭剑','赤兔','木牛'][j])+'</span></div>';
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='<div class="content-container"><div class="content"><div class="caption">桃园羁绊</div><div>先天结识关羽、张飞。保留原生标记说明内容与关闭回调。</div></div></div>';
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='<div class="equips-back"><div>装备槽</div></div>';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='<div class="content-container"><div class="content"><div class="caption">杀 · 卡牌说明</div><div>对选定敌人造成伤害。说明保留原生内容与关闭操作。</div></div></div>';
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='<div>原生技能甲</div><div>原生技能乙</div>';
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.left<b.right&&a.right>b.left&&a.top<b.bottom&&a.bottom>b.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='<div class="enable"><div class="skillitem usable">主动技能</div></div>';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='<div class="trigger"><div class="skillitem">仁德</div></div>';
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='<div class="content-container"><div class="content"><div class="caption">请弃置两张手牌</div></div></div>';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<intro.box.bottom,'description content starts inside its scrollable popup');checks++;
assert.ok(intro.box.bottom<=intro.hand.y-8&&intro.box.bottom<=intro.prompt.top&&intro.box.x>=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;});