metis-desk/scripts/filter-ui.test.py
2026-10-25 01:19:17 +02:00

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)