#!/usr/bin/env python3 """L03.2: búsquedas locales y regresión L03.1 mediante el MCP existente. Reutiliza una sesión sintética activa y el volumen de visual-audit-volume.py. No inicia servicios ni escribe entidades. Las variantes adicionales son respuestas GET. """ import json import os from pathlib import Path import runpy import unittest from urllib.parse import quote, parse_qs, urlsplit ROOT = Path(__file__).resolve().parent.parent CONTEXT = runpy.run_path(str(ROOT / "scripts/list-context-ui.test.py")) class SearchUiTests(CONTEXT["ListContextUiTests"]): @classmethod def setUpClass(cls): super().setUpClass() cls.output = ROOT / ".artifacts/playwright/l03-2/after" cls.output.mkdir(parents=True, exist_ok=True) cls.measurements = cls.output / ("measurements-" + str(os.getpid()) + ".json") def query(self, value): self.code("await page.locator('main input[data-quick-search]').fill(" + json.dumps(value) + ");return true;") self.settle() return self.search_metrics() def search_metrics(self): return self.code(r"""return page.locator('main table').first().evaluate(table=>{ const root=table.closest('[data-slot=entity-table]'),input=root.querySelector('input[data-quick-search]'); return {url:location.pathname+location.search,query:input.value,focusSearch:document.activeElement===input, count:Number(root.textContent.match(/(?:de|of) (\d+) /)?.[1]??0), sort:table.querySelector('th[aria-sort]')?.getAttribute('aria-sort'),page:root.querySelector('[aria-current=page]')?.textContent, rows:[...table.querySelectorAll('tbody tr[data-entity-id]')].map(row=>({id:row.dataset.entityId,text:row.textContent})), overflow:document.documentElement.scrollWidth>innerWidth};});""") def assert_query(self, query, count=None): value = self.query(query) self.assertEqual(value["query"], query) self.assertEqual(parse_qs(urlsplit(value["url"]).query).get("q"), [query]) self.assertTrue(value["focusSearch"]) if count is not None: self.assertEqual(value["count"], count) self.record("search-" + query, value) return value def company_fixture(self): self.code('page.searchCompanyUrl=' + json.dumps(self.origin + '/api/companies/' + self.company) + """; await page.route(page.searchCompanyUrl,async route=>{ const response=await route.fetch(),data=await response.json(),company=data.company??data; company.people.slice(0,3).forEach((person,index)=>{person.name=['Álvaro Pena','Álvaro Peña','A\\u0301lvaro Pen\\u0303a'][index]}); company.departments=Array.from({length:50},(_,index)=>({id:crypto.randomUUID(),companyId:company.id, name:(index%2?'Administración Peña':'Administracion Pena')+' PC-'+String(index).padStart(3,'0'),active:true})); await route.fulfill({response,json:data}); });return true;""") def test_15_asset_search_matrix_and_return(self): for width, height in CONTEXT["SIZES"]: self.resize(width, height) self.navigate() history_length = self.code("return page.evaluate(()=>history.length);") first = self.assert_query("portatil", 41) for query in [" PORTÁTIL ", "porta\u0301til", "rtatil"]: result = self.assert_query(query, 41) self.assertEqual(result["rows"], first["rows"]) self.assertFalse(result["overflow"]) self.assertEqual(self.code("return page.evaluate(()=>history.length);"), history_length) self.query(" portatil ") self.code("await page.getByRole('tab',{name:'Portátil',exact:true}).click();await page.getByRole('columnheader',{name:'Nombre',exact:true}).getByRole('button').click();await page.getByRole('button',{name:'Página 3',exact:true}).click();return true;") before = self.position() self.open_asset() self.code("await page.goBack();return true;") self.ready() self.wait_return_focus(before["focusRow"]) after = self.metrics() for key in ["rows", "search", "sort", "page", "x", "y", "focusRow"]: self.assertEqual(before[key], after[key], str(width) + key) self.record("accent-return-" + str(width), after) if width in [1920, 390, 320]: self.capture("asset-search-return-" + str(width)) def test_16_resource_search_context(self): self.resize(390, 844) self.navigate("resources") first = self.assert_query("aplicacion") self.assertGreaterEqual(first["count"], 50) for query in [" APLICACIÓN ", "aplicacio\u0301n"]: value = self.assert_query(query, first["count"]) self.assertEqual(value["rows"], first["rows"]) self.capture("resource-trimmed-search-390") self.code("await page.locator('main tbody tr a').first().click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');return true;") self.ready("resources") self.assertEqual(self.search_metrics()["query"], "aplicacio\u0301n") def test_17_people_unicode_and_name_scope(self): self.company_fixture() try: self.resize(390, 844) self.navigate("people") result = self.assert_query("alvaro") self.assertGreaterEqual(result["count"], 3) pena = self.assert_query("Pena", 1) self.assertTrue(all("Peña" not in row["text"] for row in pena["rows"])) enye = self.assert_query("PEN\u0303A") self.assertGreaterEqual(enye["count"], 2) self.assertTrue(all(" Pena" not in row["text"] for row in enye["rows"])) self.capture("people-enye-390") self.assert_query("person01@audit.example.test", 0) finally: self.code("await page.unroute(page.searchCompanyUrl);return true;") def test_18_catalog_names(self): self.resize(1440, 900) for path, query, label in [("/catalogs/assets", " PORTATIL ", "Portátil"), ("/catalogs/resources", "aplicacion", "Aplicación")]: self.code("await page.goto(" + json.dumps(self.origin + path) + ");await page.locator('main tbody tr[data-entity-id]').first().waitFor();return true;") value = self.assert_query(query, 1) self.assertIn(label, value["rows"][0]["text"]) self.capture("catalog-" + path.split("/")[-1]) def test_19_generic_table_and_technical_text(self): self.company_fixture() try: self.resize(1024, 768) self.navigate("departments") self.assertEqual(self.query("ADMINISTRACION")["count"], 50) self.assertEqual(self.query("Pena")["count"], 25) self.assertEqual(self.query("Peña")["count"], 25) self.assertEqual(self.query("PC-001")["count"], 1) self.assertEqual(self.query("PC001")["count"], 0) self.query("administracio\u0301n") self.capture("generic-departments-1024") finally: self.code("await page.unroute(page.searchCompanyUrl);return true;") def test_20_local_pickers_keep_provisional_selection(self): self.resize(320, 640) module = "/@fs/" + quote(str(ROOT / "packages/rdx-ui/src/picker-browser-fixture.tsx"), safe="/") for kind in ["fields", "options"]: self.navigate() self.code("await page.evaluate(async module=>{const {mountPickerFixture}=await import(module);const target=document.createElement('div');document.body.append(target);window.searchTestRoot=mountPickerFixture(target,{count:50,locale:'es',longNames:true,kind:" + json.dumps(kind) + "})}," + json.dumps(module) + ");await page.getByRole('button',{name:'Seleccionar campos',exact:true}).click();await page.getByRole('dialog').locator('input[data-quick-search]').waitFor();return true;") self.assertTrue(self.code("return page.getByRole('dialog').locator('input[data-quick-search]').evaluate(e=>e===document.activeElement);")) self.code("await page.getByRole('dialog').locator('input[data-quick-search]').fill(' DESCRIPCION TECNICA ');return true;") self.settle() selector = "checkbox" if kind == "fields" else "radio" self.code("await page.getByRole('dialog').locator('tbody').getByRole(" + json.dumps(selector) + ").first().click();await page.getByRole('dialog').locator('input[data-quick-search]').fill('no-matches-xyz');return true;") self.settle() self.assertEqual(self.code("return page.getByRole('dialog').locator('tbody tr[data-entity-id]').count();"), 0) self.code("await page.getByRole('dialog').locator('input[data-quick-search]').fill('descripcion tecnica');return true;") self.settle() self.assertEqual(self.code("return page.getByRole('dialog').locator('tbody [aria-checked=true]').count();"), 1) self.capture("picker-" + kind + "-320") self.code("await page.getByRole('button',{name:'Seleccionar',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;") self.assertIn("option-0", self.code("return page.getByLabel('Resultado',{exact:true}).textContent();")) self.code("await page.evaluate(()=>window.searchTestRoot.unmount());return true;") def test_21_query_during_refetch_and_zero_results(self): self.resize(1440, 900) self.navigate() self.code('page.searchUrl=' + json.dumps(self.origin + '/api/assets/' + self.company) + ";page.searchGates=[];page.searchStarted=new Promise(resolve=>page.searchStart=resolve);await page.route(page.searchUrl,async route=>{page.searchStart();await new Promise(resolve=>page.searchGates.push(resolve));await route.continue()});await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'))});await page.searchStarted;await page.locator('[data-query-state=refreshing]').waitFor();await page.evaluate(()=>window.searchTable=document.querySelector('main table'));return true;") try: self.assert_query(" portatil ", 41) self.code("const response=page.waitForResponse(page.searchUrl);for(const release of page.searchGates)release();await response;return true;") self.ready() self.assertTrue(self.code("return page.evaluate(()=>window.searchTable===document.querySelector('main table')&&document.activeElement.hasAttribute('data-quick-search'));")) self.assertEqual(self.search_metrics()["count"], 41) self.assert_query("no-results-xyz", 0) self.capture("zero-results-1440") self.code("await page.getByRole('button',{name:'Limpiar filtros',exact:true}).click();return true;") self.assertEqual(self.search_metrics()["query"], "") finally: self.code("for(const release of page.searchGates)release();await page.unroute(page.searchUrl);return true;") def test_22_language_and_shared_query(self): self.resize(390, 844) self.navigate(params={"q": " PORTATIL ", "sort": "name", "direction": "desc", "page": "3"}) before = self.search_metrics() 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;") self.settle() after = self.search_metrics() for key in ["query", "count", "sort", "page", "url"]: self.assertEqual(before[key], after[key]) self.assertEqual(after["count"], 41) self.record("english-original-query", after) self.capture("english-search-390") self.code("await page.reload();await page.locator('[data-query-state=ready]').waitFor();return true;") self.assertEqual(self.search_metrics()["query"], " PORTATIL ") finally: # La aplicación vuelve al idioma inicial tras recargar; solo restaurar si sigue en inglés. 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 load_tests(loader, tests, pattern): return loader.loadTestsFromTestCase(SearchUiTests) if __name__ == "__main__": unittest.main(verbosity=2)