332 lines
23 KiB
Python
332 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""L03.3: filtros y limpieza sobre sesión sintética existente mediante Playwright MCP.
|
|
|
|
Requiere visual-audit-volume.py. No inicia servicios ni escribe entidades; las sedes,
|
|
perfiles e instalaciones adicionales son respuestas GET con los contratos reales.
|
|
"""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import unittest
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
CONTEXT = runpy.run_path(str(ROOT / "scripts/list-context-ui.test.py"))
|
|
|
|
|
|
class FilterUiTests(CONTEXT["ListContextUiTests"]):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
phase = os.environ.get("METIS_FILTER_AUDIT_PHASE", "after")
|
|
if phase not in ("before", "after"):
|
|
raise ValueError("Fase inválida")
|
|
cls.output = ROOT / ".artifacts/playwright/l03-3" / phase
|
|
cls.output.mkdir(parents=True, exist_ok=True)
|
|
cls.measurements = cls.output / ("measurements-" + str(os.getpid()) + ".json")
|
|
cls.code('page.filterCompany=' + json.dumps(cls.company) + ';page.filterOrigin=' + json.dumps(cls.origin) + ";return true;")
|
|
# Aislar las interacciones de recargas HMR por trabajo simultáneo; las APIs siguen reales.
|
|
cls.code("await page.routeWebSocket(url=>url.host===new URL(page.filterOrigin).host,()=>{});return true;")
|
|
cls.volume_counts = cls.code("""const get=async(path)=>{const r=await page.request.get(page.filterOrigin+'/api'+path);if(r.status()!==200)throw Error('Estado '+r.status());return r.json()};
|
|
const data=await get('/companies/'+page.filterCompany),inventory=await get('/assets/'+page.filterCompany),resources=await get('/resources/'+page.filterCompany),catalog=await get('/resource-catalog/'+page.filterCompany),session=await get('/auth/session');
|
|
page.filterDetails=data;const company=data.company??data;
|
|
if(company.people.length<50||inventory.assets.length<50||resources.resources.length<50)throw Error('Falta volumen sintético');
|
|
const id=(n)=>'019a0000-0000-7000-a000-'+String(n).padStart(12,'0');
|
|
page.filterSites=Array.from({length:50},(_,i)=>({id:id(10000+i),companyId:company.id,name:'L03-SITE-'+String(i+1).padStart(2,'0')+' · Administración Peña',active:(i+1)%5!==0,address:'Dirección sintética '+(i+1),phone:'900000'+String(i+1).padStart(3,'0')}));
|
|
const people=company.people.filter(p=>p.active),computers=inventory.assets.filter(a=>a.values.status==='operational'&&a.specification.templateName==='Portátil');
|
|
page.filterProfiles=Array.from({length:50},(_,i)=>{const values={name:'L03-PROFILE-'+String(i+1).padStart(2,'0')+' · Administración',personId:people[i%people.length].id,assetId:computers[i%computers.length].id,accountId:null,active:(i+1)%5!==0,notes:null};return {id:id(20000+i),companyId:company.id,values,needsReview:false,history:[{id:id(30000+i),values,actorId:session.user.id,actorName:session.user.name,recordedAt:'2026-02-01T09:00:00.000Z'}]}});
|
|
const application=catalog.items.find(t=>t.builtinKey==='template:application');
|
|
const applications=resources.resources.filter(r=>r.values.active&&r.values.templateId===application.id);
|
|
if(applications.length<50)throw Error('Se necesitan cincuenta aplicaciones activas para instalaciones válidas');
|
|
page.filterAsset=inventory.assets.find(a=>a.values.name==='AUDIT-PORT-01').id;
|
|
page.filterSoftware=Array.from({length:50},(_,i)=>{const states=(i+1)%5===0?['pending','configured','removal_pending','removed']:['pending','configured'];const values={assetId:page.filterAsset,resourceId:applications[i].id,licenseResourceId:null,version:'2026.'+i,state:states.at(-1),notes:null};return {id:id(40000+i),companyId:company.id,values,needsReview:false,history:states.map((state,j)=>({id:id(50000+i*10+j),values:{...values,state},actorId:session.user.id,actorName:session.user.name,recordedAt:'2026-02-01T09:0'+j+':00.000Z'}))}});
|
|
page.filterEmpty=false;page.filterOriginal=company.sites;
|
|
await page.route(page.filterOrigin+'/api/companies/'+page.filterCompany,async route=>{await route.fulfill({status:200,contentType:'application/json',json:{...page.filterDetails,company:{...company,sites:page.filterEmpty?[]:page.filterSites}}})});
|
|
await page.route(page.filterOrigin+'/api/work-profiles/'+page.filterCompany,route=>route.fulfill({status:200,contentType:'application/json',json:{profiles:page.filterProfiles}}));
|
|
await page.route(page.filterOrigin+'/api/software-installations/'+page.filterCompany,route=>route.fulfill({status:200,contentType:'application/json',json:{configurations:page.filterSoftware}}));
|
|
return {people:company.people.length,assets:inventory.assets.length,resources:resources.resources.length};""")
|
|
|
|
def filters(self, selector="main [data-slot=entity-table]"):
|
|
return self.code('return page.locator(' + json.dumps(selector) + r""").first().evaluate(root=>{
|
|
const input=root.querySelector('input[data-quick-search]'),table=root.querySelector('table'),active=document.activeElement;
|
|
return {url:location.pathname+location.search,query:input.value,
|
|
count:Number(root.textContent.match(/(?:de|of) (\d+) /)?.[1]??0),page:root.querySelector('[aria-current=page]')?.textContent,
|
|
sort:table.querySelector('th[aria-sort]')?.getAttribute('aria-sort'),headers:[...table.querySelectorAll('th')].map(e=>e.textContent),
|
|
rows:[...table.querySelectorAll('tbody tr[data-entity-id]')].map(e=>({id:e.dataset.entityId,text:e.textContent})),
|
|
flags:[...root.querySelectorAll('[role=checkbox],input[type=checkbox]')].map(e=>({label:e.closest('label')?.textContent,checked:e.matches('input')?e.checked:e.getAttribute('aria-checked')==='true'})),
|
|
clear:[...root.querySelectorAll('button')].some(e=>['Limpiar filtros','Clear filters'].includes(e.textContent)),
|
|
pageSize:root.querySelector('footer [role=combobox]')?.textContent,focus:active.tagName,focusSearch:active===input,
|
|
text:root.textContent,overflow:document.documentElement.scrollWidth>innerWidth};});""")
|
|
|
|
def query(self, value, selector="main [data-slot=entity-table]"):
|
|
self.code('await page.locator(' + json.dumps(selector) + ').first().locator("input[data-quick-search]").fill(' + json.dumps(value) + ');return true;')
|
|
self.settle()
|
|
|
|
def clear(self, selector="main [data-slot=entity-table]", english=False):
|
|
self.code('await page.locator(' + json.dumps(selector) + ').first().getByRole("button",{name:' + json.dumps('Clear filters' if english else 'Limpiar filtros') + ',exact:true}).click();return true;')
|
|
self.settle()
|
|
|
|
def sites(self):
|
|
self.navigate("sites")
|
|
self.code("await page.getByRole('checkbox',{name:'Mostrar también las sedes archivadas',exact:true}).waitFor();return true;")
|
|
|
|
def test_21_sites_clear_contract(self):
|
|
self.resize(1440, 900)
|
|
self.sites()
|
|
self.assertEqual(self.filters()["count"], 40)
|
|
self.code("await page.getByRole('checkbox',{name:'Mostrar también las sedes archivadas',exact:true}).check();return true;")
|
|
self.query("l03")
|
|
self.record("sites-before-clear", self.filters())
|
|
self.capture("sites-before-clear-1440")
|
|
self.clear()
|
|
result = self.filters()
|
|
self.record("sites-after-clear", result)
|
|
self.capture("sites-after-clear-1440")
|
|
self.assertFalse(result["flags"][0]["checked"])
|
|
self.assertEqual(result["query"], "")
|
|
self.assertEqual(result["count"], 40)
|
|
self.assertTrue(result["focusSearch"])
|
|
|
|
def test_22_sites_only_filter_and_matrix(self):
|
|
for width, height in CONTEXT["SIZES"]:
|
|
self.resize(width, height)
|
|
self.sites()
|
|
initial = self.filters()
|
|
self.assertFalse(initial["clear"])
|
|
self.code("await page.getByRole('checkbox',{name:'Mostrar también las sedes archivadas',exact:true}).check();await page.getByRole('button',{name:'Limpiar filtros',exact:true}).waitFor();return true;")
|
|
self.settle()
|
|
self.record("sites-enabled-" + str(width), self.filters())
|
|
self.assertTrue(self.filters()["clear"])
|
|
self.clear()
|
|
result = self.filters()
|
|
self.assertEqual(result["count"], 40)
|
|
self.assertFalse(result["flags"][0]["checked"])
|
|
self.assertFalse(result["overflow"])
|
|
self.assertTrue(result["focusSearch"])
|
|
self.record("sites-cleared-" + str(width), result)
|
|
if width in [1920, 390, 320]:
|
|
self.capture("sites-cleared-" + str(width))
|
|
|
|
def test_23_sites_page_sort_and_preferences(self):
|
|
self.resize(1440, 900)
|
|
self.sites()
|
|
self.code("await page.getByRole('columnheader',{name:'Nombre',exact:true}).getByRole('button').click();await page.getByRole('button',{name:'Página 3',exact:true}).click();await page.getByRole('checkbox',{name:'Mostrar también las sedes archivadas',exact:true}).check();return true;")
|
|
self.settle()
|
|
self.assertEqual(self.filters()["page"], "1")
|
|
self.code("await page.getByRole('combobox',{name:'Elementos por página',exact:true}).click();await page.getByRole('option',{name:'25',exact:true}).click();await page.getByRole('button',{name:'Columnas',exact:true}).click();await page.getByRole('dialog').getByRole('checkbox',{name:/Dirección/}).uncheck();await page.keyboard.press('Escape');await page.getByRole('button',{name:'Página 2',exact:true}).click();return true;")
|
|
self.clear()
|
|
result = self.filters()
|
|
self.assertEqual(result["page"], "1")
|
|
self.assertEqual(result["sort"], "descending")
|
|
self.assertTrue(result["pageSize"].startswith("25"))
|
|
self.assertTrue(all('Dirección' not in value for value in result['headers']))
|
|
self.record("sites-preferences", result)
|
|
self.code("await page.getByRole('combobox',{name:'Elementos por página',exact:true}).click();await page.getByRole('option',{name:'10',exact:true}).click();return true;")
|
|
|
|
def test_24_sites_empty_and_dialog(self):
|
|
self.resize(390, 844)
|
|
self.sites()
|
|
self.query("no-matches-xyz")
|
|
self.assertIn("No hay resultados para estos criterios", self.filters()["text"])
|
|
self.clear()
|
|
self.assertEqual(self.filters()["count"], 40)
|
|
self.code("await page.getByRole('checkbox',{name:'Mostrar también las sedes archivadas',exact:true}).check();return true;")
|
|
self.query("L03-SITE-01")
|
|
before = self.filters()
|
|
self.code("await page.getByRole('button',{name:'Editar',exact:true}).first().click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
|
|
after = self.filters()
|
|
for key in ['query','flags','rows','page','sort']:
|
|
self.assertEqual(before[key],after[key])
|
|
self.code("await page.evaluate(()=>true);page.filterEmpty=true;await page.reload();return true;")
|
|
self.ready("sites")
|
|
self.query(" ")
|
|
result = self.filters()
|
|
self.assertIn("Todavía no hay sedes en esta compañía", result['text'])
|
|
self.assertNotIn("No hay resultados para estos criterios", result['text'])
|
|
self.record("sites-empty-spaces", result)
|
|
self.capture("sites-empty-spaces-390")
|
|
self.clear()
|
|
self.code("page.filterEmpty=false;return true;")
|
|
|
|
def test_25_people_and_profiles_url(self):
|
|
self.resize(1440, 900)
|
|
for section, key, label, query, expected in [('people','contactsOnly','Solo contactos','AUDIT-PERSON',self.volume_counts['people']),('work-profiles','archived','Mostrar también los perfiles archivados','l03-profile',40)]:
|
|
self.navigate(section)
|
|
self.code('await page.getByRole("checkbox",{name:' + json.dumps(label) + ',exact:true}).check();return true;')
|
|
self.query(query)
|
|
self.record(section+'-before-clear',self.filters())
|
|
self.clear()
|
|
result=self.filters();params=parse_qs(urlsplit(result['url']).query)
|
|
self.assertEqual(result['query'],'')
|
|
self.assertNotIn(key,params)
|
|
self.assertNotIn('q',params)
|
|
self.assertEqual(result['count'],expected)
|
|
self.assertEqual(result['page'],'1')
|
|
self.assertTrue(result['focusSearch'])
|
|
self.record(section+'-after-clear',result)
|
|
self.capture(section+'-after-clear-1440')
|
|
|
|
def test_26_inventory_filters_and_context(self):
|
|
self.resize(1920, 1080)
|
|
for section, total in [('assets',self.volume_counts['assets']),('resources',self.volume_counts['resources'])]:
|
|
self.navigate(section)
|
|
self.code("await page.getByRole('tab').nth(1).click();return true;")
|
|
self.query('no-matches-xyz')
|
|
self.assertEqual(self.filters()['count'],0)
|
|
self.clear()
|
|
result=self.filters();params=parse_qs(urlsplit(result['url']).query)
|
|
self.assertEqual(result['count'],total)
|
|
self.assertNotIn('template',params)
|
|
self.assertNotIn('q',params)
|
|
self.assertTrue(result['focusSearch'])
|
|
self.record(section+'-empty-cleared',result)
|
|
self.navigate('resources',{'q':'AUDIT','sort':'name','direction':'desc','page':'3'})
|
|
self.code("await page.locator('main tbody tr a').first().evaluate(e=>e.click());await page.locator('[data-slot=selection-panel]').waitFor();return true;")
|
|
before=self.filters()
|
|
self.clear()
|
|
after=self.filters()
|
|
self.assertEqual(urlsplit(before['url']).path,urlsplit(after['url']).path)
|
|
self.assertEqual(after['sort'],'descending')
|
|
self.assertEqual(after['page'],'1')
|
|
self.assertEqual(after['count'],self.volume_counts['resources'])
|
|
self.record('resources-selection-after-clear',after)
|
|
|
|
def test_27_company_scope(self):
|
|
self.resize(1440,900)
|
|
url=self.origin+'/companies?companyId='+self.company+'&activeOnly=true&q=zzzz'
|
|
self.code('await page.goto('+json.dumps(url)+');await page.locator("main table").waitFor();return true;')
|
|
self.settle()
|
|
self.clear()
|
|
result=self.filters();params=parse_qs(urlsplit(result['url']).query)
|
|
self.assertEqual(params.get('companyId'),[self.company])
|
|
self.assertNotIn('q',params)
|
|
self.assertEqual(result['count'],1)
|
|
self.assertFalse(result['flags'][0]['checked'])
|
|
self.assertFalse(result['clear'])
|
|
self.record('company-scope-cleared',result)
|
|
self.capture('company-scope-cleared-1440')
|
|
self.code('await page.goto('+json.dumps(self.origin+'/companies')+");await page.locator('main table').waitFor();await page.getByRole('columnheader',{name:'Nombre',exact:true}).getByRole('button').click();await page.getByRole('button',{name:'Página 2',exact:true}).click();return true;")
|
|
self.assertEqual(self.filters()['page'],'2')
|
|
self.code("await page.getByRole('checkbox').check();return true;")
|
|
self.settle()
|
|
self.assertEqual(self.filters()['page'],'1')
|
|
self.clear()
|
|
result=self.filters()
|
|
self.assertEqual(result['count'],15)
|
|
self.assertEqual(result['sort'],'descending')
|
|
self.assertFalse(result['flags'][0]['checked'])
|
|
self.record('company-filter-page-reset',result)
|
|
|
|
def test_28_software_filter_page(self):
|
|
self.resize(1440,900)
|
|
asset=self.code('return page.filterAsset;')
|
|
self.code('await page.goto('+json.dumps(self.origin+self.base+'/assets/'+asset)+');return true;')
|
|
selector='main [data-slot=entity-table][aria-label="Software del ordenador"]'
|
|
self.code('await page.locator('+json.dumps(selector)+').locator("tbody tr[data-entity-id]").first().waitFor();await page.locator('+json.dumps(selector)+').getByRole("button",{name:"Página 3",exact:true}).click();await page.locator('+json.dumps(selector)+').getByRole("checkbox").check();return true;')
|
|
self.settle()
|
|
result=self.filters(selector)
|
|
self.assertEqual(result['page'],'1')
|
|
self.assertEqual(result['count'],50)
|
|
self.query('zzzz',selector)
|
|
self.clear(selector)
|
|
self.assertEqual(self.filters(selector)['count'],40)
|
|
self.assertTrue(self.filters(selector)['focusSearch'])
|
|
self.record('software-after-clear',self.filters(selector))
|
|
|
|
def test_29_clear_during_loading_and_refetch(self):
|
|
self.resize(1440,900)
|
|
self.sites()
|
|
self.code("await page.getByRole('checkbox').first().check();return true;")
|
|
self.query('L03')
|
|
url=self.origin+'/api/companies/'+self.company
|
|
self.code('page.filterGateUrl='+json.dumps(url)+";page.filterGates=[];page.filterStarted=new Promise(resolve=>page.filterStart=resolve);page.filterHold=async route=>{page.filterStart();await new Promise(resolve=>page.filterGates.push(resolve));await route.fallback()};await page.route(page.filterGateUrl,page.filterHold);await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'))});await page.filterStarted;return true;")
|
|
try:
|
|
self.assertEqual(self.filters()['count'],50)
|
|
self.clear()
|
|
pending=self.filters()
|
|
self.assertEqual(pending['count'],40)
|
|
self.assertFalse(pending['flags'][0]['checked'])
|
|
self.assertTrue(pending['focusSearch'])
|
|
self.record('sites-clear-during-refetch',pending)
|
|
self.code("const response=page.waitForResponse(page.filterGateUrl);for(const release of page.filterGates)release();await response;return true;")
|
|
self.settle()
|
|
self.assertEqual(self.filters()['count'],40)
|
|
finally:
|
|
self.code("for(const release of page.filterGates)release();await page.unroute(page.filterGateUrl,page.filterHold);return true;")
|
|
url=self.origin+'/api/assets/'+self.company
|
|
self.code('page.filterGateUrl='+json.dumps(url)+";page.filterGates=[];page.filterStarted=new Promise(resolve=>page.filterStart=resolve);page.filterHold=async route=>{page.filterStart();await new Promise(resolve=>page.filterGates.push(resolve));await route.fallback()};await page.route(page.filterGateUrl,page.filterHold);return true;")
|
|
try:
|
|
self.code('await page.goto('+json.dumps(self.origin+self.base+'/assets?q=AUDIT&template='+self.laptop)+");await page.filterStarted;await page.locator('[data-query-state=loading]').waitFor();return true;")
|
|
self.clear()
|
|
pending=self.filters()
|
|
self.assertEqual(pending['query'],'')
|
|
self.assertNotIn('No hay resultados para estos criterios',pending['text'])
|
|
self.assertTrue(pending['focusSearch'])
|
|
self.record('assets-clear-during-loading',pending)
|
|
self.capture('assets-clear-during-loading-1440')
|
|
self.code("const response=page.waitForResponse(page.filterGateUrl);for(const release of page.filterGates)release();await response;return true;")
|
|
self.ready()
|
|
self.assertEqual(self.filters()['count'],self.volume_counts['assets'])
|
|
finally:
|
|
self.code("for(const release of page.filterGates)release();await page.unroute(page.filterGateUrl,page.filterHold);return true;")
|
|
|
|
def test_30_keyboard_and_language(self):
|
|
self.resize(390,844)
|
|
self.sites()
|
|
self.query('sede-no-coincidente')
|
|
self.code("await page.getByRole('button',{name:'Limpiar filtros',exact:true}).focus();await page.keyboard.press('Space');return true;")
|
|
self.assertTrue(self.filters()['focusSearch'])
|
|
self.assertEqual(self.filters()['count'],40)
|
|
self.code("await page.getByRole('checkbox').first().check();return true;")
|
|
self.query('L03')
|
|
before=self.filters()
|
|
try:
|
|
self.code("await page.locator('summary[aria-label=\"Menú de usuario\"]').click();await page.getByRole('combobox',{name:'Idioma',exact:true}).click();await page.getByRole('option',{name:'English',exact:true}).click();await page.locator('summary[aria-label=\"User menu\"]').click();return true;")
|
|
after=self.filters()
|
|
for key in ['query','count','url','page']:
|
|
self.assertEqual(before[key],after[key])
|
|
self.code("await page.getByRole('button',{name:'Clear filters',exact:true}).focus();await page.keyboard.press('Enter');return true;")
|
|
result=self.filters()
|
|
self.assertTrue(result['focusSearch'])
|
|
self.assertFalse(result['flags'][0]['checked'])
|
|
self.assertEqual(result['count'],40)
|
|
self.assertFalse(result['clear'])
|
|
self.record('english-keyboard-clear',result)
|
|
self.capture('english-keyboard-clear-390')
|
|
finally:
|
|
self.code("if(await page.locator('summary[aria-label=\"User menu\"]').count()){await page.locator('summary[aria-label=\"User menu\"]').click();await page.getByRole('combobox',{name:'Language',exact:true}).click();await page.getByRole('option',{name:'Español',exact:true}).click();await page.locator('summary[aria-label=\"Menú de usuario\"]').click()}return true;")
|
|
|
|
def test_31_invalid_flags_and_access_scope(self):
|
|
self.resize(1440,900)
|
|
self.navigate('people',{'contactsOnly':'invalid','q':'zzzz','companyId':self.company})
|
|
self.assertFalse(self.filters()['flags'][0]['checked'])
|
|
self.clear()
|
|
params=parse_qs(urlsplit(self.filters()['url']).query)
|
|
self.assertEqual(params.get('companyId'),[self.company])
|
|
self.assertEqual(self.filters()['count'],self.volume_counts['people'])
|
|
self.code('await page.goto('+json.dumps(self.origin+self.base+'/work-profiles?archived=invalid')+");await page.getByRole('alert').filter({hasText:'La dirección no es válida'}).waitFor();return true;")
|
|
self.assertEqual(self.code("return page.locator('main table').count();"),0)
|
|
self.record('invalid-archived-rejected',self.code("return page.evaluate(()=>({url:location.pathname+location.search,message:document.querySelector('[role=alert]').textContent}));"))
|
|
self.navigate('work-profiles',{'archived':'false','q':'zzzz','companyId':self.company})
|
|
self.assertFalse(self.filters()['flags'][0]['checked'])
|
|
self.clear()
|
|
self.assertEqual(self.filters()['count'],40)
|
|
self.assertEqual(parse_qs(urlsplit(self.filters()['url']).query).get('companyId'),[self.company])
|
|
self.navigate('access',{'kind':'account','environmentId':'019a0000-0000-7000-a000-000000090001','q':'zzzz','companyId':self.company})
|
|
before=parse_qs(urlsplit(self.filters()['url']).query)
|
|
self.clear()
|
|
after=parse_qs(urlsplit(self.filters()['url']).query)
|
|
for key in ['kind','environmentId','companyId']:
|
|
self.assertEqual(before.get(key),after.get(key))
|
|
self.assertNotIn('q',after)
|
|
self.record('access-scope-preserved',self.filters())
|
|
|
|
|
|
def load_tests(loader, tests, pattern):
|
|
return loader.loadTestsFromTestCase(FilterUiTests)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|