- Introduced WorkplaceMapCanvas for rendering the workplace layout using React Flow. - Added WorkplaceMapCoverage to display equipment and software coverage information. - Created WorkplaceMapLegend for visual legends in the workplace map. - Implemented WorkplaceRelationshipEdge for custom edge rendering in the map. - Developed WorkplaceSoftwareNode and WorkplaceSoftwareCard for software representation. - Added WorkplaceSoftwareHeadingNode for grouping software nodes. - Created WorkplaceSummary to summarize workstation details. - Implemented WorkplaceVisualMapCard to encapsulate the visual map logic. - Added tests for the workplace visual map functionality, covering various scenarios and edge cases. - Included styles and type declarations for new components.
222 lines
22 KiB
Python
222 lines
22 KiB
Python
#!/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',
|
|
])
|