- Implement `current-operating-systems-ui.test.py` to validate the functionality of current operating systems in the UI, including tests for source and states, computer changes, responsive design, error handling, and version saving. - Create `workplace-add-asset.test.py` to test the addition of assets to workstations, covering scenarios such as conflict resolution, responsive design on mobile, error handling during asset creation, and HTTP guards for asset management.
223 lines
22 KiB
Python
223 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/**');await page.unroute('**/api/resource-application-references/**/resolve');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});});
|
|
await page.route('**/api/resource-application-references/**/resolve',async route=>{const response=await route.fetch();const data=await response.json();for(const item of data.items??[]){if(item.status==='resolved'&&page.__mapApplications.some(r=>r.id===item.id))item.reference.name='Suite de documentación y administración — '+item.reference.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',
|
|
])
|