#!/usr/bin/env python3 """WD04.4: asociación real por HTTP y UI en metis_desk_test desechable mediante MCP.""" import json from pathlib import Path import runpy import unittest ROOT = Path(__file__).resolve().parent.parent BASE = runpy.run_path(str(ROOT / 'scripts/workplace-visual-map.test.py')) class WorkplaceAddAssetTests(BASE['WorkplaceVisualMapTests']): @classmethod def setUpClass(cls): super().setUpClass() cls.output = cls.output.parents[1] / 'wd044-add-asset' 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.__wd044={};return true;") @classmethod def code(cls, body): result = super().code("try {" + body + "} catch(error) { return {wd044Error: String(error.message).split(String.fromCharCode(10))[0]}; }") if isinstance(result, dict) and 'wd044Error' in result: raise RuntimeError(result['wd044Error']) return result def prepare(self, key='main'): self.tool('browser_resize', {'width':1440,'height':900}) self.map_ready(key, software=key=='main') # API real, autenticación ordinaria de la sesión privada del preparador. self.code("""page.__wd044.get=async path=>{const r=await page.request.get(new URL('/api'+path,page.url()).href);if(!r.ok())throw Error('GET fixture '+r.status());return r.json()}; page.__wd044.post=async(path,data,revision)=>{const session=await page.__wd044.get('/auth/session');const r=await page.request.post(new URL('/api'+path,page.url()).href,{data,headers:{'x-csrf-token':session.csrfToken,'origin':new URL(page.url()).origin,...(revision?{'if-match':'"'+revision+'"'}:{})}});return {status:r.status(),body:await r.json()}};return true;""") def open_picker(self, keyboard=False): key = 'Space' if keyboard == 'space' else 'Enter' opening = "await b.focus();await b.press(" + json.dumps(key) + ");" if keyboard else "await b.click();" self.code("const b=page.getByRole('button',{name:'Añadir activo al puesto',exact:true});" + opening + "await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor();return true;") def choose(self, name): self.code("const d=page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true});await d.locator('[data-quick-search]').fill("+json.dumps(name)+");await d.getByRole('radio',{name:"+json.dumps('Seleccionar '+name)+",exact:true}).press('Space');return true;") def test_01_existing_cancel_conflict(self): self.prepare() data=self.code("""const cid="""+json.dumps(self.fixture['companyId'])+""";const inv=await page.__wd044.get('/assets/'+cid);const w=inv.workstations.find(x=>x.id==="""+json.dumps(self.fixture['main'])+""");const occupied=new Set(inv.workstations.filter(x=>x.values.active).flatMap(x=>x.values.assetIds));const candidate=inv.assets.find(x=>x.values.status==='operational'&&!occupied.has(x.id));page.__wd044.before=w;page.__wd044.candidate=candidate;return {name:candidate.values.name,previous:w.values.assetIds,principal:w.values.computerAssetId,history:w.history.length};""") self.open_picker(keyboard=True) self.assertTrue(self.code("return await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).locator('[data-quick-search]').evaluate(e=>e===document.activeElement);")) self.assertEqual(self.code("return await page.getByRole('radio',{name:'Seleccionar PORT-ADM-01 · Documentación y gestión',exact:true}).count();"),0) self.choose(data['name']) self.code("await page.keyboard.press('Escape');return true;") self.assertTrue(self.code("return await page.getByRole('button',{name:'Añadir activo al puesto',exact:true}).evaluate(e=>e===document.activeElement);")) self.open_picker() self.choose(data['name']) # Otro usuario cambia el nombre mientras la selección permanece abierta. conflict=self.code("const w=page.__wd044.before;return await page.__wd044.post('/assets/'+w.companyId+'/workstations/'+w.id+'/update',{values:{...w.values,name:'Administración — cambio concurrente WD04.4'}},w.history.at(-1).id);") self.assertEqual(conflict['status'],200) self.code("await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('alert').filter({hasText:'Otro usuario ha modificado'}).waitFor();return true;") self.capture('S06-conflict') self.code("await page.getByRole('button',{name:'Actualizar puesto y revisar selección',exact:true}).click();await page.getByRole('button',{name:'Añadir al puesto',exact:true}).waitFor({state:'visible'});return true;") self.choose(data['name']) self.code("await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor({state:'hidden'});return true;") result=self.code("const b=page.__wd044.before;const inv=await page.__wd044.get('/assets/'+b.companyId);const w=inv.workstations.find(x=>x.id===b.id);return {ids:w.values.assetIds,principal:w.values.computerAssetId,history:w.history.length,name:w.values.name,actionPersisted:w.values.assetIds.some(x=>x.includes('presentation:'))};") self.assertEqual(len(result['ids']),len(data['previous'])+1) self.assertTrue(set(data['previous']).issubset(result['ids'])) self.assertEqual(result['principal'],data['principal']) self.assertEqual(result['history'],data['history']+2) self.assertFalse(result['actionPersisted']) self.record('S01-S03-S05-S06',result) self.capture('S01-associated') def test_02_responsive_empty_mobile(self): self.prepare('empty') 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.settle() self.open_picker(keyboard='space' if width == 1600 else True) self.capture('picker-'+str(width)) geometry=self.code("return await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,searchFocused:document.activeElement.matches('[data-quick-search]'),dialogWidth:document.querySelector('[role=dialog]').getBoundingClientRect().width}));") self.assertLessEqual(geometry['scrollWidth'],width) self.assertTrue(geometry['searchFocused']) self.record('responsive-'+str(width),geometry) self.code("await page.keyboard.press('Escape');return true;") self.assertTrue(self.code("return await page.getByRole('button',{name:'Añadir activo al puesto',exact:true}).evaluate(e=>e===document.activeElement);")) # Asociación móvil en puesto sin principal, preservando null. candidate=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');const occupied=new Set(inv.workstations.filter(w=>w.values.active).flatMap(w=>w.values.assetIds));const a=inv.assets.find(a=>a.values.status==='operational'&&!occupied.has(a.id));return a.values.name;") before_count=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');return inv.workstations.find(w=>w.id==='"+self.fixture['empty']+"').values.assetIds.length;") self.open_picker() self.choose(candidate) self.code("await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor({state:'hidden'});return true;") result=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');const w=inv.workstations.find(w=>w.id==='"+self.fixture['empty']+"');return {principal:w.values.computerAssetId??null,count:w.values.assetIds.length};") self.assertIsNone(result['principal']) self.assertEqual(result['count'],before_count+1) self.capture('S02-S10-mobile-associated') def test_03_create_cancel_save_error_refresh(self): self.prepare('withoutResponsible') created_name=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');page.__wd044.newName='MON-WD044 '+(inv.assets.filter(a=>a.values.name.startsWith('MON-WD044')).length+1);return page.__wd044.newName;") self.open_picker() self.code("const d=page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true});await d.locator('[data-quick-search]').fill('WD044 selección conservada');await d.getByRole('button',{name:'Crear activo nuevo',exact:true}).click();await page.getByRole('dialog',{name:'Nuevo activo',exact:true}).waitFor();return true;") self.capture('S04-create-form') self.code("await page.keyboard.press('Escape');return true;") self.assertEqual(self.code("return await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).locator('[data-quick-search]').inputValue();"),'WD044 selección conservada') self.code("await page.getByRole('button',{name:'Crear activo nuevo',exact:true}).click();const d=page.getByRole('dialog',{name:'Nuevo activo',exact:true});await d.getByRole('combobox',{name:'Plantilla',exact:true}).click();await page.getByRole('option',{name:'Monitor',exact:true}).click();await d.getByRole('button',{name:'Siguiente',exact:true}).click();await d.getByRole('button',{name:'Siguiente',exact:true}).click();await d.getByRole('textbox',{name:'Nombre',exact:true}).fill(page.__wd044.newName);await d.getByRole('button',{name:'Guardar',exact:true}).click();await d.waitFor({state:'hidden'});return true;") self.assertTrue(self.code("return await page.getByRole('radio',{name:'Seleccionar '+page.__wd044.newName,exact:true}).isChecked();")) result=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');const a=inv.assets.find(a=>a.values.name===page.__wd044.newName);page.__wd044.created=a;return {created:!!a,assigned:inv.workstations.some(w=>w.values.assetIds.includes(a.id))};") self.assertTrue(result['created']) self.assertFalse(result['assigned']) self.capture('S04-created-selected') self.code("await page.keyboard.press('Escape');return true;") self.open_picker() self.choose(created_name) # Fallo de escritura: el activo ya creado permanece y se reintenta la misma asociación. self.code("await page.route('**/workstations/*/update',route=>route.fulfill({status:500,json:{code:'INTERNAL_ERROR'}}));await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).getByRole('alert').waitFor();return true;") self.capture('S07-save-error') self.code("await page.unroute('**/workstations/*/update');page.__wd044.writes=0;page.__wd044.failRead=false;await page.route('**/workstations/*/update',async route=>{page.__wd044.writes++;const response=await route.fetch();if(response.ok())page.__wd044.failRead=true;await route.fulfill({response})});await page.route('**/api/assets/"+self.fixture['companyId']+"',async route=>{if(page.__wd044.failRead)await route.fulfill({status:500,json:{code:'INTERNAL_ERROR'}});else await route.fallback()});await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('alert').filter({hasText:'El activo se ha asociado'}).waitFor();return true;") self.capture('S07-saved-refresh-error') self.code("page.__wd044.failRead=false;await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).getByRole('button',{name:'Reintentar',exact:true}).click();await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor({state:'hidden'});return true;") self.assertEqual(self.code("return page.__wd044.writes;"),1) self.code("await page.unroute('**/workstations/*/update');await page.unroute('**/api/assets/"+self.fixture['companyId']+"');return true;") result=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');return {assetStillExists:inv.assets.some(a=>a.id===page.__wd044.created.id),associated:inv.workstations.find(w=>w.id==='"+self.fixture['withoutResponsible']+"').values.assetIds.includes(page.__wd044.created.id)};") self.assertTrue(result['assetStillExists']) self.assertTrue(result['associated']) self.record('S04-S05-S07',result) def test_04_visual_limit_and_http_guards(self): self.prepare('dense') before=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');const w=inv.workstations.find(w=>w.id==='"+self.fixture['dense']+"');const occupied=new Set(inv.workstations.filter(w=>w.values.active).flatMap(w=>w.values.assetIds));const a=inv.assets.find(a=>a.values.status==='operational'&&!occupied.has(a.id));page.__wd044.denseBefore=w;return {name:a.values.name,count:w.values.assetIds.length,principal:w.values.computerAssetId,history:w.history.length};") self.open_picker() self.choose(before['name']) self.code("await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor({state:'hidden'});return true;") result=self.code("const b=page.__wd044.denseBefore;const inv=await page.__wd044.get('/assets/'+b.companyId);const w=inv.workstations.find(w=>w.id===b.id);page.__wd044.denseCurrent=w;return {count:w.values.assetIds.length,principal:w.values.computerAssetId,history:w.history.length,coverage:await page.locator('[data-workplace-coverage=equipment]').innerText(),realNodes:await page.locator('[data-workplace-map] article[data-workplace-equipment]').count(),actions:await page.locator('[data-workplace-add]').count()};") self.assertEqual(result['count'],before['count']+1) self.assertEqual(result['principal'],before['principal']) self.assertEqual(result['history'],before['history']+1) self.assertEqual(result['realNodes'],3) self.assertEqual(result['actions'],1) self.assertIn(str(before['count']+1),result['coverage']) self.record('S08-limit-counters',result) self.capture('S08-limit') guards=self.code("""const w=page.__wd044.denseCurrent;const path='/assets/'+w.companyId+'/workstations/'+w.id+'/update';const duplicate=await page.__wd044.post(path,{values:{...w.values,assetIds:[...w.values.assetIds,w.values.assetIds[0]]}},w.history.at(-1).id); const occupied=await page.__wd044.post(path,{values:{...w.values,assetIds:[...w.values.assetIds,"""+json.dumps(self.fixture['host'])+"""]}},w.history.at(-1).id); const other=await page.__wd044.post('/assets/"""+self.fixture['otherCompany']+"""/workstations',{values:{name:'Aislamiento WD04.4',active:false,siteId:null,personId:null,computerAssetId:null,assetIds:[]}}); const foreign=await page.__wd044.post('/assets/"""+self.fixture['otherCompany']+"""/workstations/'+other.body.workstation.id+'/update',{values:{...other.body.workstation.values,assetIds:[w.values.assetIds[0]]}},other.body.workstation.history.at(-1).id); const anonymousContext=await page.context().browser().newContext();let anonymous;try{const response=await anonymousContext.request.get(new URL('/api/assets/"""+self.fixture['companyId']+"""',page.url()).href);anonymous=response.status()}finally{await anonymousContext.close()}; return {duplicate:{status:duplicate.status,code:duplicate.body.code},occupied:{status:occupied.status,code:occupied.body.code},foreign:{status:foreign.status,code:foreign.body.code},anonymous};""") self.assertEqual(guards['duplicate']['code'],'INVALID_MEMBERS') self.assertEqual(guards['occupied']['code'],'ASSET_IN_WORKSTATION') self.assertEqual(guards['foreign']['code'],'ASSET_NOT_FOUND') self.assertEqual(guards['anonymous'],401) self.record('HTTP-MariaDB-guards',guards) if __name__ == '__main__': unittest.main(defaultTest=['WorkplaceAddAssetTests.test_01_existing_cancel_conflict','WorkplaceAddAssetTests.test_02_responsive_empty_mobile','WorkplaceAddAssetTests.test_03_create_cancel_save_error_refresh','WorkplaceAddAssetTests.test_04_visual_limit_and_http_guards'])