#!/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)