#!/usr/bin/env python3 """Recorrido MCP del mapa en el entorno desechable documentado; no modifica datos reales.""" import json import os from pathlib import Path import runpy import unittest ROOT = Path(__file__).resolve().parent.parent BASE = runpy.run_path(str(ROOT / 'scripts/workstation-dashboard-ui.test.py')) class WorkplaceVisualMapTests(BASE['WorkstationDashboardTests']): @classmethod def setUpClass(cls): super().setUpClass() phase = os.environ.get('METIS_MAP_CAPTURE_PHASE', 'after') if phase not in ('before', 'after'): raise RuntimeError('Fase de captura no válida.') cls.phase = phase cls.output = cls.output.parent / 'visual-map-iteration-2' / phase cls.output.mkdir(parents=True, exist_ok=True) evidence = cls.output / "measurements.json" cls.evidence = json.loads(evidence.read_text()) if evidence.exists() else [] cls.code("page.__mapNetwork={active:0,waiters:[]};await page.route('**/api/**',async route=>{const gate=page.__mapNetwork;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;") def map_ready(self, key='main', software=True, pending=False): self.navigate(key, pending=key != 'empty' and pending) self.code("await page.getByRole('heading',{name:'Mapa visual del puesto',exact:true}).waitFor();return true;") if key == 'main' and software: self.code("await page.getByRole('link',{name:'Gestor documental de ejemplo',exact:true}).first().waitFor();return true;") self.settle() def test_map(self): for width, height in [(1600,1000),(1440,900),(1280,900),(1024,768),(768,1024),(390,844)]: self.tool('browser_resize', {'width': width, 'height': height}) self.map_ready(pending=True) self.code("await page.getByRole('heading',{name:'Mapa visual del puesto',exact:true}).scrollIntoViewIfNeeded();return true;") geometry = self.code("""return await page.evaluate(()=>{ const map=document.querySelector('[data-workplace-map]');const box=map.getBoundingClientRect(); const nodes=[...map.querySelectorAll('article')].map(e=>{const b=e.getBoundingClientRect();return {text:e.innerText,kind:e.dataset.workplaceEquipment??'software',x:b.x,y:b.y,width:b.width,height:b.height,scrollWidth:e.scrollWidth,clientWidth:e.clientWidth,scrollHeight:e.scrollHeight,clientHeight:e.clientHeight}}); const canvas=map.querySelector('[data-workplace-canvas]')?.getBoundingClientRect();const heading=map.querySelector('.react-flow__node-softwareHeading');const h=heading?.getBoundingClientRect();return {viewport:innerWidth,scrollWidth:document.documentElement.scrollWidth,mapWidth:box.width,canvas:!!canvas,canvasBounds:canvas?{left:canvas.left,right:canvas.right,top:canvas.top,bottom:canvas.bottom,height:canvas.height}:null,heading:h?{x:h.x,y:h.y,width:h.width,height:h.height,text:heading.innerText,tabIndex:heading.getAttribute('tabindex')}:null,handlesHidden:[...map.querySelectorAll('.react-flow__handle')].every(e=>getComputedStyle(e).opacity==='0'),nodes,edges:map.querySelectorAll('.react-flow__edge').length}; });""") self.record(f'layout-{width}', geometry) self.assertLessEqual(geometry['scrollWidth'], width) self.assertGreaterEqual(len(geometry['nodes']), 3) self.assertEqual(geometry['canvas'], geometry['mapWidth'] >= 640) if geometry['canvas']: self.assertEqual(geometry['edges'], len(geometry['nodes']) - 1) for node in geometry['nodes']: self.assertGreater(node['width'], 140) self.assertLessEqual(node['scrollWidth'], node['clientWidth'] + 1) if self.phase == 'after': self.assertLessEqual(node['scrollHeight'], node['clientHeight'] + 1) if geometry['canvas']: for index, first in enumerate(geometry['nodes']): for second in geometry['nodes'][index + 1:]: self.assertTrue(first['x'] + first['width'] <= second['x'] + 1 or second['x'] + second['width'] <= first['x'] + 1 or first['y'] + first['height'] <= second['y'] + 1 or second['y'] + second['height'] <= first['y'] + 1, 'Los nodos no se solapan') if self.phase == 'after' and geometry['canvas']: main = next(node for node in geometry['nodes'] if node['kind'] == 'principal') for node in geometry['nodes']: if node['kind'] == 'associated': self.assertGreater(main['width'], node['width']) self.assertGreater(main['height'], node['height']) bounds = geometry['canvasBounds'] self.assertGreaterEqual(node['x'], bounds['left'] - 1) self.assertLessEqual(node['x'] + node['width'], bounds['right'] + 1) self.assertGreaterEqual(node['y'], bounds['top'] - 1) self.assertLessEqual(node['y'] + node['height'], bounds['bottom'] + 1) self.assertEqual(geometry['heading']['text'], 'Software del ordenador principal') self.assertIsNone(geometry['heading']['tabIndex']) self.assertTrue(geometry['handlesHidden']) self.assertLessEqual(bounds['height'], 500) self.capture(f'map-{width}') def test_dense(self): self.tool('browser_resize', {'width': 1600, 'height': 1000}) self.map_ready('dense', software=False) self.assertEqual(self.code("return await page.locator('[data-workplace-map] article').count();"), 3) self.code("await page.locator('[data-workplace-coverage=equipment]').getByText('Mostrando 3 de 80 activos',{exact:true}).waitFor();return true;") self.capture('dense') self.code("await page.getByRole('button',{name:'Ver todo el equipamiento',exact:true}).click();await page.getByRole('region',{name:/^Equipamiento/}).getByRole('button',{name:'Mostrar todos',exact:true}).click();return true;") count = self.code("return await page.getByRole('region',{name:/^Equipamiento/}).locator('[data-slot=compact-list-item]').count();") self.assertEqual(count, 80) self.record('dense', {'members': count, 'profiles': 60, 'visibleMapNodes': 3}) def test_navigation_and_management(self): self.tool('browser_resize', {'width': 1440, 'height': 900}) self.map_ready() target = self.base + '/assets/' + self.fixture['host'] keyboard = self.code("await page.getByRole('button',{name:'Gestionar equipamiento',exact:true}).focus();await page.keyboard.press('Tab');return await page.evaluate(()=>({name:document.activeElement?.getAttribute('aria-label'),outline:getComputedStyle(document.activeElement).outlineStyle}));") self.assertIn('PORT-ADM-01', keyboard['name']) self.assertEqual(keyboard['outline'], 'solid') self.record('keyboard', keyboard) result = self.code("const link=page.locator('[data-workplace-map]').getByRole('link',{name:/PORT-ADM-01/});await link.focus();const focused=await link.evaluate(e=>e===document.activeElement);await page.keyboard.press('Enter');await page.locator('main h1').waitFor();return {focused,url:new URL(page.url()).pathname};") self.assertTrue(result['focused']) self.assertEqual(result['url'], target) self.map_ready() self.code("await page.getByRole('button',{name:'Gestionar equipamiento',exact:true}).click();await page.getByRole('dialog',{name:'Editar puesto',exact:true}).waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancelar',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;") self.assertTrue(self.code("return await page.getByRole('button',{name:'Gestionar equipamiento',exact:true}).evaluate(e=>e===document.activeElement);")) remote = self.code("const trigger=page.getByRole('button',{name:'Contexto remoto',exact:true});await trigger.focus();await page.keyboard.press('Space');const expanded=await trigger.getAttribute('aria-expanded');await page.keyboard.press('Space');return {expanded,collapsed:await trigger.getAttribute('aria-expanded')};") self.assertEqual(remote, {'expanded': 'true', 'collapsed': 'false'}) self.expand_primary() self.code("await page.getByRole('heading',{name:/Personas y perfiles/}).scrollIntoViewIfNeeded();return true;") self.capture('profiles-1440') self.code("await page.getByRole('button',{name:'Gestionar software',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});await page.getByRole('tab',{name:'Historial',exact:true}).click();await page.getByRole('heading',{name:'Historial del puesto',exact:true}).waitFor();return true;") self.record('navigation-management', result) def test_partial_errors_and_empty(self): self.tool('browser_resize', {'width': 1280, 'height': 900}) self.addCleanup(self.code, "await page.unroute('**/api/software-installations/**');await page.unroute('**/api/mail-configurations/**');return true;") self.map_ready('empty') self.assertEqual(self.code("return await page.locator('[data-workplace-map] article').count();"), 0) self.capture('without-principal') self.map_ready('main') self.code("await page.route('**/api/software-installations/**',route=>route.fulfill({status:500,json:{code:'INTERNAL_ERROR'}}));return true;") self.map_ready('main', software=False) self.assertGreaterEqual(self.code("return await page.locator('[data-workplace-map] article').count();"), 2) self.code("await page.getByRole('region',{name:'Mapa visual del puesto',exact:true}).getByRole('alert').first().waitFor();return true;") self.capture('software-error') self.record('partial-error', {'equipmentRemains': True}) self.code("await page.unroute('**/api/software-installations/**');await page.route('**/api/software-installations/**',route=>route.fulfill({status:200,json:{configurations:[]}}));return true;") self.map_ready('main', software=False) self.code("await page.getByRole('region',{name:'Mapa visual del puesto',exact:true}).getByText('No hay software documentado para el ordenador principal.',{exact:true}).waitFor();return true;") self.capture('without-software') self.code("await page.route('**/api/mail-configurations/**',route=>route.fulfill({status:500,json:{code:'INTERNAL_ERROR'}}));return true;") self.map_ready('main', software=False, pending=False) self.code("await page.getByRole('region',{name:'Configuraciones pendientes',exact:true}).getByRole('alert').first().waitFor();return true;") self.assertGreaterEqual(self.code("return await page.locator('[data-workplace-map] article').count();"), 2) self.capture('configuration-error') self.record('partial-configurations', {'equipmentRemains': True}) def test_principal_only_and_resize(self): self.tool('browser_resize', {'width': 1600, 'height': 1000}) self.map_ready('withoutResponsible', software=False) self.code("await page.getByText('No hay software documentado para el ordenador principal.',{exact:true}).waitFor();return true;") initial = self.code("return await page.locator('[data-workplace-canvas]').evaluate(e=>({height:e.getBoundingClientRect().height,nodes:e.querySelectorAll('article').length}));") self.assertEqual(initial['nodes'], 1) self.assertLess(initial['height'], 300) self.capture('principal-only') self.record('principal-only', initial) for width in [1280,768,390,1440]: self.tool('browser_resize', {'width': width, 'height': 1000}) self.code("await page.waitForFunction(()=>{const map=document.querySelector('[data-workplace-map]');return !!map.querySelector('.react-flow')===(map.getBoundingClientRect().width>=640)});return true;") self.settle() geometry = self.code("return await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,nodes:document.querySelectorAll('[data-workplace-map] article').length,name:document.querySelector('[data-workplace-map] a').getAttribute('aria-label')}));") self.assertEqual(geometry['nodes'], 1) self.assertLessEqual(geometry['scrollWidth'], width) self.assertIn('PC-LAB-01', geometry['name']) self.record(f'resize-{width}', geometry) def test_software_coverage_states_and_refetch(self): self.tool('browser_resize', {'width':1600,'height':1000}) self.map_ready() self.addCleanup(self.code, "await page.unroute('**/api/software-installations/**');await page.unroute('**/api/resources/**');return true;") prepared = self.code("""const res=await page.request.get('"""+self.origin+"""/api/resources/"""+self.fixture['companyId']+"""');if(res.status()!==200)throw Error('Recursos sintéticos no disponibles');const inventory=await res.json();page.__mapApplications=inventory.resources.filter(r=>r.values.name.startsWith('AUDIT-VOLUME-APP-')).slice(0,8);if(page.__mapApplications.length!==8)throw Error('Se requieren ocho aplicaciones sintéticas'); await page.route('**/api/resources/**',async route=>{const response=await route.fetch();const data=await response.json();for(const resource of data.resources??[]){if(page.__mapApplications.some(r=>r.id===resource.id))resource.values.name='Suite de documentación y administración — '+resource.values.name+' — nombre largo para teclado';}await route.fulfill({response,json:data});}); page.__mapSoftwareFailure=false; await page.route('**/api/software-installations/**',async route=>{if(page.__mapSoftwareFailure){await route.fulfill({status:500,json:{code:'INTERNAL_ERROR'}});return;}const response=await route.fetch();const data=await response.json();const base=data.configurations.find(c=>c.values.assetId==='"""+self.fixture['host']+"""');if(!base)throw Error('Falta instalación base');const configurations=page.__mapApplications.map((resource,index)=>{const state=['configured','pending','removal_pending'][index%3];const values={...base.values,resourceId:resource.id,licenseResourceId:null,state};return {...base,id:'0199b200-0000-7000-8000-'+String(800+index).padStart(12,'0'),values,needsReview:state==='removal_pending'||!resource.values.active,history:base.history.map(revision=>({...revision,values}))};});await route.fulfill({response,json:{...data,configurations:[...data.configurations.filter(c=>c.values.assetId!==base.values.assetId),...configurations]}});});return {installations:page.__mapApplications.length};""") self.record('software-scenario', prepared) self.map_ready(software=False) self.code("await page.getByText('Mostrando 3 de 8 instalaciones de software',{exact:true}).waitFor();return true;") states = self.code("return await page.locator('[data-workplace-map] [data-workplace-software] [data-slot=badge]').allTextContents();") labels = json.loads((ROOT / 'modules/companies/src/common/locales/es.json').read_text())['companies']['mail'] self.assertCountEqual(states, [labels[state] for state in ['configured','pending','removal_pending']]) self.assertTrue(self.code("return await page.evaluate(()=>[...document.querySelectorAll('[data-workplace-software]')].some(e=>e.textContent.includes('Requiere revisión')));")) self.code("const link=page.locator('[data-workplace-map] [data-workplace-software] a').first();page.__mapTooltipName=await link.getAttribute('aria-label');await link.focus();await page.keyboard.press('Tab');await page.keyboard.press('Shift+Tab');await page.locator('[data-slot=tooltip-content]').filter({hasText:page.__mapTooltipName}).waitFor();return true;") self.assertTrue(self.code("return (await page.locator('[data-slot=tooltip-content]').filter({hasText:page.__mapTooltipName}).innerText()).includes('nombre largo para teclado');")) self.code("await page.keyboard.press('Escape');return true;") sizes = self.code("return await page.locator('[data-workplace-map] [data-workplace-software]').evaluateAll(nodes=>nodes.map(e=>({height:e.clientHeight,scrollHeight:e.scrollHeight,width:e.clientWidth,scrollWidth:e.scrollWidth})));") self.assertTrue(all(size['scrollHeight']<=size['height']+1 and size['scrollWidth']<=size['width']+1 for size in sizes)) self.record('long-titles', sizes) self.capture('software-coverage-states') self.code("await page.getByRole('button',{name:'Ver todo el software',exact:true}).click();return true;") self.assertTrue(self.code("return await page.getByRole('button',{name:'Gestionar software',exact:true}).evaluate(e=>e===document.activeElement);")) self.code("await page.getByRole('button',{name:'Gestionar software',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});await page.getByRole('heading',{name:'Mapa visual del puesto',exact:true}).scrollIntoViewIfNeeded();return true;") self.code("await page.evaluate(()=>{window.__mapRetained=document.querySelector('[data-workplace-canvas]');window.__mapCamera=document.querySelector('[data-workplace-canvas] .react-flow__viewport').style.transform;});return true;") self.code("const response=page.waitForResponse(r=>r.url().includes('/api/software-installations/'));await page.evaluate(()=>{Object.defineProperty(document,'visibilityState',{configurable:true,value:'hidden'});window.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'visibilityState',{configurable:true,value:'visible'});window.dispatchEvent(new Event('visibilitychange'));delete document.visibilityState;});await response;return true;") self.settle() retained = self.code("return await page.evaluate(()=>({sameCanvas:window.__mapRetained===document.querySelector('[data-workplace-canvas]'),sameCamera:window.__mapCamera===document.querySelector('[data-workplace-canvas] .react-flow__viewport').style.transform,nodes:document.querySelectorAll('[data-workplace-map] article').length}));") self.assertTrue(retained['sameCanvas']) self.assertTrue(retained['sameCamera']) self.assertEqual(retained['nodes'], 5) self.record('refetch-retained', retained) self.code("page.__mapSoftwareFailure=true;await page.evaluate(()=>{Object.defineProperty(document,'visibilityState',{configurable:true,value:'hidden'});window.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'visibilityState',{configurable:true,value:'visible'});window.dispatchEvent(new Event('visibilitychange'));delete document.visibilityState;});await page.getByRole('region',{name:'Mapa visual del puesto',exact:true}).getByRole('alert').first().waitFor();return true;") self.assertEqual(self.code("return await page.locator('[data-workplace-coverage=software]').count();"),0) self.assertEqual(self.code("return await page.locator('[data-workplace-map] article').count();"),5) self.capture('software-stale-no-total') def test_contrast_and_reduced_motion(self): self.tool('browser_resize', {'width':1440,'height':900}) self.code("await page.emulateMedia({reducedMotion:'reduce'});return true;") self.addCleanup(self.code,"await page.emulateMedia({reducedMotion:'no-preference'});return true;") self.map_ready() values = self.code("""return await page.evaluate(()=>{ const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const ctx=canvas.getContext('2d',{willReadFrequently:true}); const rgba=color=>{ctx.clearRect(0,0,1,1);ctx.fillStyle=color;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=>e?over(rgba(getComputedStyle(e).backgroundColor),bg(e.parentElement)):[255,255,255,1]; 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 results=[...document.querySelectorAll('[data-workplace-map] article span,[data-workplace-map] .react-flow__node-softwareHeading span,aside[aria-label="Leyenda"] span')].filter(e=>e.innerText.trim()&&!e.classList.contains('sr-only')&&e.getBoundingClientRect().width).map(e=>({text:e.innerText,contrast:ratio(over(rgba(getComputedStyle(e).color),bg(e)),bg(e))})); return {reducedMotion:matchMedia('(prefers-reduced-motion: reduce)').matches,results}; });""") self.record('contrast-and-motion', values) self.assertTrue(values['reducedMotion']) for value in values['results']: self.assertGreaterEqual(value['contrast'],4.5,value['text']) if __name__ == '__main__': unittest.main(defaultTest=[ 'WorkplaceVisualMapTests.test_map', 'WorkplaceVisualMapTests.test_dense', 'WorkplaceVisualMapTests.test_navigation_and_management', 'WorkplaceVisualMapTests.test_partial_errors_and_empty', 'WorkplaceVisualMapTests.test_principal_only_and_resize', 'WorkplaceVisualMapTests.test_software_coverage_states_and_refetch', 'WorkplaceVisualMapTests.test_contrast_and_reduced_motion', ])