metis-desk/scripts/workstation-uiux-v3.test.py

267 lines
28 KiB
Python
Raw Normal View History

2026-10-24 23:19:17 +00:00
#!/usr/bin/env python3
"""Mediciones MCP de estados, carga y regresión sobre el preparador desechable."""
import json
import os
from pathlib import Path
import runpy
import unittest
ROOT = Path(__file__).resolve().parent.parent
PREVIOUS = runpy.run_path(str(ROOT / 'scripts/workstation-uiux-v22.test.py'))
AUDIT = PREVIOUS['AUDIT']
CONTRAST = r"""() => {
const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const ctx=canvas.getContext('2d',{willReadFrequently:true});
const rgba=value=>{ctx.clearRect(0,0,1,1);ctx.fillStyle=value;ctx.fillRect(0,0,1,1);return [...ctx.getImageData(0,0,1,1).data].map((x,i)=>i===3?x/255:x)};
const over=(a,b)=>{const alpha=a[3]+b[3]*(1-a[3]);return [...a.slice(0,3).map((v,i)=>(v*a[3]+b[i]*b[3]*(1-a[3]))/alpha),alpha]};
const bg=e=>{if(!e)return [255,255,255,1];const p=bg(e.parentElement),s=getComputedStyle(e),color=over(rgba(s.backgroundColor),p);return over([...color.slice(0,3),Number(s.opacity)],p)};
const luminance=c=>c.slice(0,3).map(x=>x/255).map(x=>x<=.04045?x/12.92:((x+.055)/1.055)**2.4).reduce((a,x,i)=>a+x*[.2126,.7152,.0722][i],0);
const ratio=(a,b)=>{const l=[luminance(a),luminance(b)].sort((a,b)=>b-a);return (l[0]+.05)/(l[1]+.05)};
const candidates=[...document.querySelectorAll('main [data-slot=badge],main [data-pending-count],main .tabular-nums,main [class*=text-amber],main [role=alert],main [role=status],main p,main button,main a,main span.inline-flex')];
return candidates.filter(e=>e instanceof HTMLElement&&e.getBoundingClientRect().width&&e.innerText.trim()&&!e.closest('[hidden]')).map(e=>{
const s=getComputedStyle(e),background=bg(e),fg=over(rgba(s.color),over(rgba(s.backgroundColor),bg(e.parentElement))),foreground=over([...fg.slice(0,3),Number(s.opacity)],bg(e.parentElement));
const dot=e.matches('span.inline-flex')?e.querySelector('span.rounded-full'):null;
const big=parseFloat(s.fontSize)>=24||(parseFloat(s.fontSize)>=18.6667&&Number(s.fontWeight)>=700);
return {location:e.closest('section[aria-label]')?.getAttribute('aria-label')||document.getElementById(e.closest('section[aria-labelledby]')?.getAttribute('aria-labelledby'))?.textContent||'Cabecera',tag:e.tagName,slot:e.dataset.slot??null,variant:e.dataset.variant??null,role:e.getAttribute('role'),text:e.innerText.trim(),className:e.className.split(' ').filter(c=>/amber|tabular|text-|bg-|font-/.test(c)).join(' '),color:s.color,background:s.backgroundColor,effectiveForeground:foreground,effectiveBackground:background,contrast:ratio(foreground,background),fontSize:s.fontSize,fontWeight:s.fontWeight,minimum:big?3:4.5,disabled:!!e.disabled,opacity:s.opacity,border:s.borderColor,indicator:dot?{color:getComputedStyle(dot).backgroundColor,contrast:ratio(rgba(getComputedStyle(dot).backgroundColor),background),decorative:dot.getAttribute('aria-hidden')==='true'}:null};
}).filter(e=>e.slot==='badge'||e.className.includes('amber')||e.className.includes('tabular-nums')||e.indicator||['BUTTON','A','P'].includes(e.tag)||['alert','status'].includes(e.role));
}"""
GEOMETRY = r"""() => {
const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height}};
const status=[...document.querySelectorAll('main [role=status]')].find(e=>e.getAttribute('aria-label')?.includes('ficha'));
const skeletons=[...document.querySelectorAll('main [data-slot=skeleton]')];
const panels=[...document.querySelectorAll('main section[aria-labelledby]')].map(e=>({title:document.getElementById(e.getAttribute('aria-labelledby'))?.textContent,...rect(e)}));
return {viewport:innerWidth,scrollWidth:document.documentElement.scrollWidth,main:rect(document.querySelector('main')),loadingStatus:status?rect(status):null,skeletons:skeletons.map(rect),panels};
}"""
class WorkstationUiuxV3Tests(PREVIOUS['WorkstationUiuxV22Tests']):
@classmethod
def setUpClass(cls):
print('Iniciando sesión MCP de v3',flush=True)
super().setUpClass()
print('Sesión MCP preparada',flush=True)
cls.output=ROOT/'docs/auditorias/ficha-puesto-trabajo/implementacion-v3'
(cls.output/cls.phase).mkdir(parents=True,exist_ok=True)
cls.measurements=json.loads((cls.output/'mediciones.json').read_text()) if (cls.output/'mediciones.json').exists() else {}
state=ROOT/os.environ['METIS_PLAYWRIGHT_STORAGE_STATE']
cls.fixture_file=state.parent/'workstation-v3-fixtures.json'
if cls.fixture_file.exists():cls.fixture=json.loads(cls.fixture_file.read_text())
cls.code("page.__v3Network={active:0,waiters:[]};await page.route('**/api/**',async route=>{const gate=page.__v3Network;if(gate.active<2)gate.active++;else await new Promise(resolve=>gate.waiters.push(resolve));try{const response=await route.fetch();await route.fulfill({response});}finally{const next=gate.waiters.shift();if(next)next();else gate.active--}});return true;")
cls.save('networkCondition',{'maximumConcurrentBrowserApiRequests':2,'reason':'Lecturas anidadas de Recursos agotan el pool de cinco conexiones sin control de red. No modifica queries ni servidor.'})
def navigate(self,key="main",complete=True):
try:
return super().navigate(key,complete)
except RuntimeError:
self.save('lastNavigationFailure',self.code("return {errors:page.__uiuxErrors,...await page.evaluate(()=>({url:location.href,text:document.body.innerText,overlay:document.querySelector('vite-error-overlay')?.shadowRoot?.textContent.slice(0,500)}))};"))
raise
def test_06_regressions(self):
try:
return super().test_06_regressions()
except RuntimeError:
self.save('regressionFailure',self.code("return {errors:page.__uiuxErrors,...await page.evaluate(()=>({url:location.href,text:document.body.innerText}))};"))
raise
def test_11_states_and_contrast(self):
for theme in ['light','dark']:
self.tool('browser_resize',{'width':1440,'height':900});self.navigate();self.expand_primary()
self.code("await page.evaluate(dark=>document.documentElement.classList.toggle('dark',dark),"+str(theme=='dark').lower()+");return true;")
self.code("await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));return true;")
values=self.code('return await page.evaluate('+CONTRAST+');')
self.save('contrast-'+theme,values)
if self.phase=='despues':
for value in values:
if not value['disabled']:self.assertGreaterEqual(value['contrast'],value['minimum'],value.get('location','')+': '+value['text'])
self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;")
self.capture('estados-'+theme+'-1440')
button="page.getByRole('region',{name:/^Software/}).getByRole('button',{name:'Gestionar software',exact:true})"
self.code("await page.locator('main header').first().getByRole('button',{name:'Editar puesto',exact:true}).hover();return true;")
self.save('header-hover-'+theme,self.code('return await page.evaluate('+CONTRAST+');'))
self.code('await '+button+'.hover();return true;')
hover=self.code('return await page.evaluate('+CONTRAST+');')
if self.phase=='despues':
for value in hover:
if not value['disabled']:self.assertGreaterEqual(value['contrast'],value['minimum'],value.get('location','')+': '+value['text'])
self.code('await '+button+'.focus();await '+button+".press('Tab');await page.keyboard.press('Shift+Tab');return true;")
focus=self.code('return {styles:await '+button+".evaluate(e=>{const s=getComputedStyle(e);return {focus:e===document.activeElement,focusVisible:e.matches(':focus-visible'),boxShadow:s.boxShadow,outline:s.outline,border:s.borderColor}}),colors:await page.evaluate("+CONTRAST+')} ;')
self.capture('foco-'+theme+'-1440');self.save('interaction-colors-'+theme,{'hover':hover,'focus':focus})
self.navigate('empty');self.code("await page.evaluate(dark=>document.documentElement.classList.toggle('dark',dark),"+str(theme=='dark').lower()+");return true;")
self.save('empty-colors-'+theme,self.code('return await page.evaluate('+CONTRAST+');'))
self.tool('browser_resize',{'width':390,'height':844});self.navigate();self.code("await page.evaluate(dark=>document.documentElement.classList.toggle('dark',dark),"+str(theme=='dark').lower()+");return true;");self.expand_primary();self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;");self.capture('estados-'+theme+'-390')
self.code("await page.evaluate(()=>document.documentElement.classList.remove('dark'));return true;")
def test_12_controlled_loading(self):
selected_widths={int(x)for x in os.environ.get('METIS_UIUX_LOAD_WIDTHS','1440,390,320').split(',')}
selected_kinds=set(os.environ.get('METIS_UIUX_LOAD_KINDS','inventory,profiles,software,support').split(','))
if not selected_widths<={1440,390,320} or not selected_kinds<={'inventory','profiles','software','support'}:raise ValueError('Selección de carga desconocida')
for width,height in [(1440,900),(390,844),(320,720)]:
if width not in selected_widths:continue
self.tool('browser_resize',{'width':width,'height':height})
for kind,path in [('inventory','/assets/'),('profiles','/work-profiles/'),('software','/software-installations/'),('support','/asset-support/')]:
if kind not in selected_kinds:continue
route='**/api'+path+self.fixture['companyId']+('/'+self.fixture['host'] if kind=='support' else '')
self.code('await page.route('+json.dumps(route)+",r=>{page.__heldV3=r;page.__v3Resolve()});page.__heldV3Ready=new Promise(resolve=>{page.__v3Resolve=resolve});await page.addInitScript(()=>{if(window.__v3Observer)return;window.__v3Shifts=[];window.__v3Observer=new PerformanceObserver(list=>{for(const e of list.getEntries())window.__v3Shifts.push({value:e.value,recent:e.hadRecentInput,time:e.startTime,sources:e.sources.map(s=>({node:s.node?.tagName,previous:s.previousRect,current:s.currentRect}))})});window.__v3Observer.observe({type:'layout-shift',buffered:true})});return true;")
try:
self.tool('browser_navigate',{'url':self.base+'/workstations/'+self.fixture['main']})
self.code("await page.waitForFunction(()=>Boolean(document.querySelector('main [data-slot=skeleton]')));return true;")
if kind!='inventory':self.code("await page.getByRole('region',{name:/^Equipamiento/}).waitFor();return true;")
# La ruta retenida controla la transición; no hay espera temporal artificial.
self.code("await page.waitForFunction(()=>document.fonts.status==='loaded');await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));return true;")
self.code("await page.__heldV3Ready;return true;")
initial=self.code('return await page.evaluate('+GEOMETRY+');')
image=AUDIT['capture'](self.client,self.output/self.phase,os.environ.get('METIS_UIUX_LOAD_LABEL','carga')+'-'+kind+'-'+str(width))
self.code("if(!page.__heldV3)throw Error('La ruta no se retuvo');await page.evaluate(()=>window.__v3Release=performance.now());await page.__heldV3.continue();delete page.__heldV3;return true;")
self.code("await page.locator('[data-pending-count]').waitFor();await page.getByRole('link',{name:'Ángela · administración',exact:true}).waitFor();await page.getByText('equipo-adm-01.example.invalid',{exact:false}).first().waitFor();await page.getByRole('region',{name:/^Software/}).getByText('Gestor documental de ejemplo',{exact:true}).waitFor();await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));return true;")
final=self.code('return await page.evaluate('+GEOMETRY+');')
shifts=self.code('return await page.evaluate(()=>({releaseTime:window.__v3Release,supported:PerformanceObserver.supportedEntryTypes.includes("layout-shift"),entries:window.__v3Shifts}));')
self.save(os.environ.get('METIS_UIUX_LOAD_LABEL','carga')+'-'+kind+'-'+str(width),{'image':image,'initial':initial,'final':final,'layoutShifts':shifts})
self.assertLessEqual(final['scrollWidth'],width)
print('Carga verificada: '+kind+' '+str(width),flush=True)
except RuntimeError:
self.save('lastLoadingFailure',self.code("return await page.evaluate(()=>({text:document.body.innerText,url:location.href}));"))
raise
finally:self.code('if(page.__heldV3){await page.__heldV3.continue();delete page.__heldV3}await page.unroute('+json.dumps(route)+');return true;')
def test_13_initial_error_and_refetch(self):
route='**/api/assets/'+self.fixture['companyId']
self.tool('browser_resize',{'width':390,'height':844})
self.code('await page.route('+json.dumps(route)+",r=>r.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));return true;")
try:
self.tool('browser_navigate',{'url':self.base+'/workstations/'+self.fixture['main']});self.code("await page.getByRole('alert').waitFor();return true;")
image=AUDIT['capture'](self.client,self.output/self.phase,'error-inicial-390');self.save('initialError',{'colors':self.code('return await page.evaluate('+CONTRAST+');'),'image':image,'text':self.code("return await page.locator('main').innerText();")})
finally:self.code('await page.unroute('+json.dumps(route)+');return true;')
self.code("await page.getByRole('button',{name:'Reintentar',exact:true}).click();await page.locator('[data-pending-count]').waitFor();return true;");self.navigate()
self.code('await page.route('+json.dumps(route)+",r=>{page.__heldV3=r});await page.context().setOffline(true);await page.context().setOffline(false);return true;")
try:
self.code("await page.getByRole('region',{name:/^Equipamiento/}).getByText('Actualizando…',{exact:true}).waitFor();return true;")
self.capture('refetch-inventario-390');self.assertEqual(self.code("return await page.locator('main h1').count();"),1)
finally:self.code('if(page.__heldV3){await page.__heldV3.continue();delete page.__heldV3}await page.unroute('+json.dumps(route)+');return true;')
self.save('recovery',{'initialRetry':True,'refetchKeepsData':True})
def test_14_accessibility_capabilities(self):
self.tool('browser_resize',{'width':1440,'height':900});self.navigate();self.expand_primary()
semantics=self.code("const session=await page.context().newCDPSession(page);const {nodes}=await session.send('Accessibility.getFullAXTree');await session.detach();return nodes.filter(n=>!n.ignored&&['heading','table','row','columnheader','button','link','alert','status'].includes(n.role?.value)).map(n=>({role:n.role.value,name:n.name?.value,properties:n.properties}));")
self.save('accessibilityTree',semantics)
before=self.code("return await page.evaluate(()=>({inner:innerWidth,outer:outerWidth,dpr:devicePixelRatio}));")
self.code("await page.keyboard.press('Control+Equal');return true;")
shortcut=self.code("return await page.evaluate(()=>({inner:innerWidth,outer:outerWidth,dpr:devicePixelRatio}));")
self.code("await page.keyboard.press('Control+Digit0');return true;")
probe=self.code("const settings=await page.context().newPage();try{await settings.goto('chrome://settings/appearance',{timeout:10000});const select=settings.locator('settings-appearance-page select#zoomLevel');await select.waitFor({timeout:10000});return {available:true,options:await select.locator('option').evaluateAll(es=>es.map(e=>({text:e.textContent,value:e.value}))),settingsUrl:settings.url()};}catch(error){return {available:false,reason:String(error).split('\\n')[0]};}finally{await settings.close()}")
self.save('nativeZoomProbe',{'before':before,'shortcut':shortcut,'settings':probe})
def test_15_review_pending_and_unknown(self):
print('Estados: revisión, retirada pendiente y desconocido',flush=True)
folder='**/api/folder-mappings/'+self.fixture['companyId']
software='**/api/software-installations/'+self.fixture['companyId']
for route,mutation in [(folder,"for(const c of json.mappings){c.needsReview=true;c.values.state='removal_pending'}"),(software,"for(const [i,c]of json.configurations.entries()){c.values.state=i%2?'removal_pending':'pending'}")]:
self.code('await page.route('+json.dumps(route)+",async r=>{const response=await r.fetch();const json=await response.json();"+mutation+"await r.fulfill({response,json})});return true;")
try:
for theme in ['light','dark']:
self.tool('browser_resize',{'width':1440,'height':900});self.navigate();self.expand_primary();self.code("await page.evaluate(dark=>document.documentElement.classList.toggle('dark',dark),"+str(theme=='dark').lower()+");return true;")
self.code("await page.getByText('Requiere revisión',{exact:true}).waitFor();await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));return true;")
self.save('review-contrast-'+theme,self.code('return await page.evaluate('+CONTRAST+');'))
self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;");self.capture('revision-'+theme+'-1440')
except (RuntimeError,AssertionError):
self.save('reviewDiagnostic',self.code("return {text:await page.locator('main').innerText(),errors:page.__uiuxErrors};"))
raise
finally:
for route in [folder,software]:self.code('await page.unroute('+json.dumps(route)+');return true;')
self.code("await page.evaluate(()=>document.documentElement.classList.remove('dark'));return true;")
route='**/api/folder-mappings/'+self.fixture['companyId']
self.code('await page.route('+json.dumps(route)+",r=>r.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));return true;")
try:
for theme in ['light','dark']:
self.navigate(complete=False);self.code("await page.evaluate(dark=>document.documentElement.classList.toggle('dark',dark),"+str(theme=='dark').lower()+");await page.getByRole('link',{name:'Ángela · administración',exact:true}).waitFor();await page.getByText('No se puede confirmar el total de pendientes.',{exact:true}).waitFor();return true;")
self.save('unknown-contrast-'+theme,self.code('return await page.evaluate('+CONTRAST+');'))
self.capture('pendientes-desconocidos-'+theme+'-1440')
finally:self.code('await page.unroute('+json.dumps(route)+");await page.evaluate(()=>document.documentElement.classList.remove('dark'));return true;")
def test_16_reduced_motion(self):
route='**/api/assets/'+self.fixture['companyId']
self.code('await page.emulateMedia({reducedMotion:"reduce"});await page.route('+json.dumps(route)+",r=>{page.__heldV3=r});return true;")
try:
self.tool('browser_navigate',{'url':self.base+'/workstations/'+self.fixture['main']})
self.code("await page.waitForFunction(()=>document.querySelectorAll('main [role=status] [data-slot=skeleton]').length>=20);return true;")
value=self.code("return await page.evaluate(()=>({prefersReducedMotion:matchMedia('(prefers-reduced-motion:reduce)').matches,animations:[...document.querySelectorAll('main [role=status] [data-slot=skeleton]')].map(e=>getComputedStyle(e).animationName)}));")
self.assertTrue(value['prefersReducedMotion']);self.assertTrue(all(x=='none'for x in value['animations']));self.save('reducedMotion',value)
finally:self.code('if(page.__heldV3){await page.__heldV3.continue();delete page.__heldV3}await page.unroute('+json.dumps(route)+');await page.emulateMedia({reducedMotion:"no-preference"});return true;')
def test_17_band_container_thresholds(self):
self.tool('browser_resize',{'width':1600,'height':1000});self.navigate();results=[]
for width in [899,900,901,999,1000,1001]:
value=self.code("return await page.evaluate(async width=>{const root=document.querySelector('main h1').closest('[class*=container]');root.style.width=width+'px';await new Promise(requestAnimationFrame);const names=['Equipamiento','Configuraciones pendientes','Personas y perfiles','Software'];const panels=names.map(name=>{const e=[...root.querySelectorAll('section[aria-labelledby]')].find(e=>document.getElementById(e.getAttribute('aria-labelledby'))?.textContent.startsWith(name));const r=e.getBoundingClientRect();return {name,x:r.x,y:r.y,width:r.width,height:r.height}});return {container:root.getBoundingClientRect().width,panels}},"+str(width)+");")
ps=value['panels'];self.assertEqual(ps[1]['x']>ps[0]['x'],width>=900);self.assertEqual(ps[3]['x']>ps[2]['x'],width>=1000);results.append(value)
self.save('bandThresholds',results)
self.code("await page.locator('main h1').evaluate(e=>e.closest('[class*=container]').style.removeProperty('width'));return true;")
def test_18_remaining_state_captures(self):
for width,height in [(1440,900),(390,844)]:
self.tool('browser_resize',{'width':width,'height':height})
for key,label in [('noProfiles','vacios-confirmados'),('retired','desmantelado'),('peripherals','sin-principal-con-perifericos')]:
self.navigate(key);self.capture(label+'-'+str(width))
def test_19_shared_telework_consumers(self):
labels=json.loads((ROOT/'modules/companies/src/common/locales/es.json').read_text())['companies']['telework']
route='**/api/telework-authorizations/'+self.fixture['companyId']
cases=[('workstation',self.base+'/workstations/'+self.fixture['main']),('environment',self.base+'/work-profiles/'+self.fixture['primaryProfile']),('telework',self.base+'/telework-authorizations')]
for review in [False,True]:
if review:self.code('await page.route('+json.dumps(route)+",async r=>{const response=await r.fetch();const json=await response.json();for(const a of json.authorizations){a.needsReview=true;a.effective=false}await r.fulfill({response,json})});return true;")
try:
for name,url in cases:
for theme in ['light','dark']:
self.tool('browser_resize',{'width':1440,'height':900});self.tool('browser_navigate',{'url':url})
self.code("await page.locator('p').filter({hasText:/^Autorizada$/}).waitFor();await page.evaluate(dark=>document.documentElement.classList.toggle('dark',dark),"+str(theme=='dark').lower()+");return true;")
if review:self.code('await page.getByText('+json.dumps(labels['review'])+',{exact:true}).waitFor();return true;')
colors=self.code('return (await page.evaluate('+CONTRAST+')).filter(x=>'+json.dumps(['Autorizada',labels['review']])+'.includes(x.text));')
self.assertTrue(colors);self.assertTrue(all(x['contrast']>=4.5 for x in colors))
self.code("await page.locator('p').filter({hasText:/^Autorizada$/}).scrollIntoViewIfNeeded();return true;")
key='teletrabajo-'+name+'-'+('revision-'if review else '')+theme
value={'colors':colors,'review':review,'theme':theme,'url':url}
if not review or name=='workstation':value['image']=AUDIT['capture'](self.client,self.output/self.phase,key)
self.save(key,value)
finally:
if review:self.code('await page.unroute('+json.dumps(route)+');return true;')
self.code("await page.evaluate(()=>document.documentElement.classList.remove('dark'));return true;")
def test_20_archived_status_contrast(self):
self.tool('browser_resize',{'width':1440,'height':900});self.navigate('dense')
self.code("const p=page.getByRole('region',{name:/^Personas y perfiles/});await p.getByRole('button',{name:'Mostrar todos',exact:true}).click();await p.getByRole('button',{name:'10 perfiles archivados',exact:true}).click();return true;")
for theme in ['light','dark']:
self.code("await page.evaluate(dark=>document.documentElement.classList.toggle('dark',dark),"+str(theme=='dark').lower()+");return true;")
value=self.code('return (await page.evaluate('+CONTRAST+')).filter(x=>x.text==="Inactivo"&&x.indicator).slice(0,1);')
self.assertEqual(len(value),1);self.assertGreaterEqual(value[0]['contrast'],4.5);self.save('archived-contrast-'+theme,value)
self.code("await page.evaluate(()=>document.documentElement.classList.remove('dark'));return true;")
def test_21_equipment_state_contrast(self):
route='**/api/assets/'+self.fixture['companyId'];host=self.fixture['host']
self.tool('browser_resize',{'width':1440,'height':900})
for state in ['operational','broken','maintenance','retired']:
self.code('await page.route('+json.dumps(route)+",async r=>{const response=await r.fetch();const json=await response.json();for(const a of json.assets){if(a.id==="+json.dumps(host)+")a.values.status="+json.dumps(state)+"}await r.fulfill({response,json})});return true;")
try:
self.navigate()
for theme in ['light','dark']:
self.code("await page.evaluate(dark=>document.documentElement.classList.toggle('dark',dark),"+str(theme=='dark').lower()+");return true;")
colors=self.code('return (await page.evaluate('+CONTRAST+')).filter(x=>x.slot==="badge"&&x.location.startsWith("Equipamiento"));')
self.assertTrue(colors);self.save('equipment-'+state+'-'+theme,colors)
if self.phase=='despues':self.assertTrue(all(x['contrast']>=4.5 for x in colors),state+' '+theme)
if state=='retired':self.capture('equipamiento-retirado-'+theme+'-1440')
finally:self.code('await page.unroute('+json.dumps(route)+');return true;')
self.code("await page.evaluate(()=>document.documentElement.classList.remove('dark'));return true;")
def test_22_final_mobile_review(self):
self.tool('browser_resize',{'width':320,'height':720});self.navigate('dense')
bounds=self.code("const p=page.getByRole('region',{name:/^Personas y perfiles/});await p.locator('[data-slot=card-header]').scrollIntoViewIfNeeded();const card=await p.boundingBox();const buttons=await p.getByRole('button').evaluateAll(es=>es.filter(e=>e.textContent.includes('archivados')||e.textContent.includes('Nuevo perfil')).map(e=>{const r=e.getBoundingClientRect();return {name:e.textContent,x:r.x,right:r.right,y:r.y,bottom:r.bottom,width:r.width}}));return {card,buttons};")
self.assertEqual(len(bounds['buttons']),2)
for button in bounds['buttons']:
self.assertLessEqual(button['right'],bounds['card']['x']+bounds['card']['width'])
self.assertGreaterEqual(button['y'],106)
self.assertLessEqual(button['bottom'],720)
self.capture('perfiles-archivados-320');self.save('mobileArchivedHeader',bounds)
self.tool('browser_resize',{'width':390,'height':844});self.navigate()
self.code("await page.getByRole('region',{name:/^Software/}).getByText('Gestor documental de ejemplo',{exact:true}).waitFor();await page.locator('p').filter({hasText:/^Autorizada$/}).waitFor();await page.evaluate(()=>document.documentElement.classList.add('dark'));return true;")
self.capture('principal-oscuro-390');self.code("await page.evaluate(()=>document.documentElement.classList.remove('dark'));return true;")
errors=self.code('return page.__uiuxErrors;');self.assertEqual(errors,[]);self.save('finalPageErrors',errors)
if __name__=='__main__':unittest.main()