#!/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()