metis-desk/scripts/workplace-visual-map.test.py

222 lines
22 KiB
Python
Raw Normal View History

#!/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',
])