feat: add column browser fixture and entity column configuration

- Introduced `ColumnBrowserFixture` component for testing column visibility and interactions.
- Created `entity-column-configuration.ts` to define types and utility functions for managing entity columns.
- Implemented Playwright tests in `columns-ui.test.py` to validate column behavior and preferences.
- Added `visual-audit-volume.py` script to enhance the visual audit setup with synthetic data.
This commit is contained in:
David Arranz 2026-10-22 10:10:34 +02:00
parent db57346c24
commit ba034794c6
25 changed files with 1293 additions and 31 deletions

View File

@ -164,7 +164,7 @@ Fuente normativa: [patrón de listados](docs/patron-de-listados-de-entidades.md)
[EntityTable](packages/rdx-ui/src/components/entity-list.tsx) compone bandas, estados, contenido y controles; [useEntityTable](packages/rdx-ui/src/hooks/use-entity-table.ts) gestiona TanStack Table, orden, visibilidad y paginación **locales**. Nombre comienza ascendente salvo criterio explícito; comparación por idioma, sin tildes/mayúsculas y con cifras numéricas. Esto no describe el comportamiento de búsqueda: V07 sigue pendiente.
Columnas de inventario persisten con clave propia en localStorage; otros consumidores deben habilitar una clave explícita. Identificación y acciones no se ocultan. El selector extenso necesita revisión, V08. La búsqueda y ciertos filtros de rutas se conservan según su contrato; la restauración completa de orden/página no está implementada de forma general, V05. Cancelar un modal debe mantener contexto del listado.
Columnas de inventario persisten con clave propia en localStorage; otros consumidores deben habilitar una clave explícita. Identificación y acciones no se ocultan. [L02.2](docs/configuracion-columnas-l02-2.md) incorpora búsqueda de columnas por etiqueta/keywords, grupos semánticos opcionales, visibilidad efectiva y restauración inmediata de defaults, conservando preferencias y protecciones (V08). Los listados sencillos no necesitan grupos. La búsqueda y ciertos filtros de rutas se conservan según su contrato; la restauración completa de orden/página no está implementada de forma general, V05. Cancelar un modal debe mantener contexto del listado.
Familias por plantilla usan `ScrollableTabsList`; `min-w-0` y scroll horizontal quedan dentro del listado. `onRowClick` solo selecciona desde zonas no interactivas, conservando enlaces, menús y controles internos. No añadir selección múltiple sin operación consumidora.
@ -272,7 +272,7 @@ V02, V03, V06 y V09 están **resueltos en los escenarios verificados del sprint
| V05 | Estado/navegación de implementación | Definir restauración completa por vista; orden reproducido, página de activos pendiente por datos |
| V06 | Resuelto: integración de foco del inventario | Ratón/Enter, Escape/Cancelar, cabecera/fila, alternativa y selector anidado verificados |
| V07 | Contrato de búsqueda y consumidores | Acordar normalización por idioma y probar tildes/ñ; no confundir con ordenación |
| V08 | Evolución del selector de columnas | Diseñar búsqueda/agrupación con API genérica; conservar preferencias |
| V08 | Resuelto en los escenarios verificados de L02.2 | Búsqueda, grupos opcionales, contador y restauración con preferencias compatibles |
| V09 | Resuelto: etiquetas/i18n de seis listados | Cabeceras breves y motivos conservados; tests es/en y navegador |
| V10 | Jerarquía de ficha | Diseñar resumen de vacíos y grupos sin ocultar relaciones/acciones; validar fixture poblada |
| V11 | Renderizadores y semántica de ausencia | Aplicar pauta de «—» distinguiendo estado de negocio |

View File

@ -723,3 +723,24 @@ Validación documental: comprobación de rutas de código y documentos, enlaces/
Se consultaron las referencias pertinentes de composición y diferencias Base UI/Radix, no se ejecutaron generadores ni comandos de instalación. `ui-ux-pro-max` está disponible, pero no se aplicó: los problemas concretos quedaron cubiertos por las skills especializadas, los contratos de Metis y la evidencia; no se ejecutó por rutina. `web-design-guidelines` y los auditores especializados a los que algunas skills delegan no están disponibles en el catálogo de esta sesión; no se instalaron ni se afirmó haberlos usado. La referencia `ux-tables/docs/orchestration-policy.md` no existe en la instalación localizada; se trabajó con el SKILL y las fuentes realmente accesibles.
Precedencia aplicada: solicitud L01 y restricciones de AGENTS → DESIGN/patrones especializados → APIs reales → recomendaciones de skills. Se descartó ocultar primarias solo en hover, trasladar la selección al final, imponer 16 px/44 px a todos los controles compactos, cambiar fuentes/colores, introducir SWR/Next o reemplazar tablas por tarjetas. Ninguna recomendación genérica justifica modificar vendor, instalar dependencias o declarar una validación visual que no se ejecutó.
## Seguimiento L02.2 — V08
El 22 de octubre de 2026, [L02.2](configuracion-columnas-l02-2.md) corrige V08 mediante
búsqueda por etiqueta/keywords, grupos opcionales, contador efectivo y Restaurar columnas.
Se conservan los IDs, preferencias, protecciones y aplicación inmediata. Activos aporta
cinco categorías; Recursos, Personas y selectores sencillos no necesitan grupos artificiales.
La reproducción nueva confirma las **75 columnas, 55 dinámicas y ocho visibles** del
catálogo de esta instalación. Las medidas originales de la sección 9 se conservan como
línea base. El diálogo nuevo a 320×640 mantiene 608 px exteriores y 336 px útiles de opciones;
búsqueda/restauración y cierre permanecen accesibles. Marca, compartida por doce plantillas,
aparece una sola vez y puede localizarse por distintos contextos.
La suite MCP final ejecuta **8/8 pruebas correctas**, con siete resoluciones, es/en,
restauración/recarga, storage bloqueado, campos nuevos/retirados, teclado y foco. La compañía
principal desechable contiene 50 personas, 51 activos y 66 recursos, dentro de quince
compañías sintéticas. Las evidencias y los fallos globales previos figuran en el informe.
**V08 queda resuelto en los escenarios verificados. V05, V07 y V12 permanecen pendientes.**
No cambia el estado de los demás hallazgos ni se implementa L02.3.

View File

@ -493,3 +493,28 @@ El 22 de octubre de 2026 se completa [V01.4](recursos-responsive-v01-4.md), cons
La comprobación final mediante Docker/Chromium y Playwright MCP autenticado ejecuta **28/28 métodos correctos**, incluida regresión de V01.1–V01.4 y V02/V03/V06/V09. Las matrices cubren once resoluciones y ambos lados de los umbrales; no registran overflow horizontal global. Las cuatro suites finales y las matrices no registran errores ni warnings de consola. Se amplían fixtures con 16 recursos sintéticos del catálogo real y se verifican lectura ordinaria, filtros/paginación, relaciones disponibles, edición/cancelación, transiciones y foco; no se realizan borrados ni activaciones.
**V01 pasa de parcialmente resuelto a resuelto en los escenarios verificados.** Los fallos globales previos siguen documentados como tales: calendario vendor en typecheck, lint/check ajenos y dos expectativas SSR de compañías; las comprobaciones dirigidas y build pasan. No se extiende esta conclusión a WCAG completo, otros motores, datos reales o los hallazgos fuera de alcance. El informe V01.4 conserva las regresiones intermedias y sus correcciones, las evidencias locales y la retirada de servicios/sesión propios.
## Seguimiento L02.2 — Configuración de columnas V08
El 22 de octubre de 2026 se completa [L02.2](configuracion-columnas-l02-2.md). **V08 pasa a
resuelto en los escenarios verificados**: búsqueda con tildes/mayúsculas y distinción ñ/n,
keywords, cinco grupos en Activos, contador de visibilidad, columnas obligatorias y
restauración inmediata con persistencia tras recarga. Sin grupos, el configurador continúa
sirviendo a Recursos, Personas y tablas sencillas. No hay Aplicar/Cancelar ni reordenación.
Se reproduce antes del cambio el catálogo real de 75 columnas y ocho visibles, sin
búsqueda/grupos, y la preferencia Sede guardada. Se preservan las medidas originales del
hallazgo. La nueva suite MCP completa obtiene **8/8 pruebas correctas**, cubriendo siete
resoluciones de 320×640 a 1920×1080, es/en, casos de tres columnas, campo compartido entre
doce plantillas, storage bloqueado, diálogos anidados, teclado, foco y recarga.
A 320×640 las opciones disponen de 336 px útiles y el diálogo permanece en 608 px exteriores;
filtrar hasta cero resultados no colapsa la lista ni desplaza búsqueda/cierre fuera del modal.
Las capturas representativas se abren para inspección, además de medir geometría. Se utiliza
una única instalación autenticada desechable, con quince compañías y una principal con 50
personas, 51 activos y 66 recursos. El informe enlaza evidencias y registra los resultados
técnicos fallidos sin presentarlos como suites globales correctas.
**V05, V07 y V12 siguen pendientes.** La normalización añadida solo corresponde al buscador
de columnas; no corrige las búsquedas de entidades. No se cambia el estado de otros hallazgos
ni se acredita WCAG completo o pruebas con datos reales.

View File

@ -1180,6 +1180,29 @@ almacenamiento contenga una preferencia contraria. Los datos inválidos o el alm
bloqueado no impiden utilizar la tabla. Cambiar de clave carga las preferencias de la
nueva vista sin escribir sobre ellas la selección de la anterior.
### Configuración avanzada de columnas — L02.2
`EntityColumn` admite `groupId?: string` y `keywords?: readonly string[]`.
`EntityTable.columnGroups` aporta grupos `{ id, label, description? }`, en orden de
presentación; sus textos pertenecen al consumidor. Los componentes genéricos no conocen
plantillas, DTO ni endpoints. Sin grupos, el diálogo conserva una lista sencilla.
`EntityTableColumnsDialog` compone FormDialog en modo picker, InputGroup, Checkbox,
FieldSet/FieldLegend y FormBody. Búsqueda y restauración permanecen fuera del scroll de
opciones. El resumen refleja visibilidad efectiva; la columna técnica `selection` no se
expone ni contabiliza. Las obligatorias siguen marcadas/deshabilitadas y tienen descripción
accesible. Se mantiene aplicación inmediata y Cerrar, sin Aplicar/Cancelar.
`EntityTableConfiguration` transmite opcionalmente `columnGroups`, `columnMetadata`
(`id`, `groupId?`, `keywords?`) y `onResetColumns`. La integración EntityTable vacía solo sus
preferencias locales, recuperando defaults actuales aunque las columnas lleguen después.
El uso directo del diálogo sin callback recurre a `table.resetColumnVisibility()`.
Activos aporta General, Estado y ubicación, Auditoría, Infraestructura y Características del
catálogo. Un UUID compartido aparece una sola vez; plantillas/secciones y etiquetas históricas
se incorporan como keywords. Se conserva `asset-inventory-columns` y su formato de booleanos.
Véase [API, políticas, pruebas y evidencias](configuracion-columnas-l02-2.md).
### Navegación de páginas mediante cursor
`CursorPagination` de `@repo/rdx-ui/components/cursor-pagination` presenta anterior,

View File

@ -0,0 +1,210 @@
# L02.2 — Configuración avanzada de columnas (V08)
Fecha: 22 de octubre de 2026. Segunda entrega de [L01](auditoria-listados-l01.md), sobre la composición de [L02.1](selectores-accesibilidad-l02-1.md).
## 1. Resumen de implementación
**V08 resuelto en los escenarios verificados**, con ocho pruebas MCP específicas y cuatro regresiones conjuntas correctas.
EntityTable incorpora búsqueda local de columnas, agrupación semántica opcional, contador de visibilidad efectiva y restauración inmediata. Activos aporta cinco grupos; Recursos, Personas y los selectores sencillos conservan una presentación sin categorías artificiales. No cambia la ordenación real de columnas ni se añade un borrador Aplicar/Cancelar.
La corrección se limita a V08. V05, V07 y V12 permanecen pendientes; la normalización del selector de columnas no cambia las búsquedas de entidades. No se modifican vendor, rutas, autenticación, contratos HTTP, persistencia del negocio, tokens ni dependencias.
## 2. Arquitectura y API
[EntityColumn](../packages/rdx-ui/src/components/entity-list.tsx) conserva `id`, `label`, `render`, `value`, `defaultSort`, `hideable` y `defaultVisible`. Añade opcionalmente `groupId?: string` y `keywords?: readonly string[]`.
`EntityTable.columnGroups?: readonly EntityColumnGroup[]` recibe grupos `{ id, label, description? }`; su orden en el array es el orden de presentación. El consumidor aporta etiquetas y descripciones traducidas. No se implementa `align` en esta entrega.
```tsx
<EntityTable
columns={[
{ id: "name", label: t("name"), groupId: "general", hideable: false, render: renderName },
{ id: "notes", label: t("notes"), groupId: "general", keywords: [t("description")], defaultVisible: false, render: renderNotes },
]}
columnGroups={[{ id: "general", label: t("general") }]}
items={items}
title={t("title")}
/>
```
[EntityTableConfiguration](../packages/rdx-ui/src/components/entity-table-configuration.tsx) transmite metadatos y grupos al [diálogo](../packages/rdx-ui/src/components/entity-table-columns-dialog.tsx). Los nuevos parámetros del diálogo/configurador son opcionales. `columnMetadata` es una colección de `{ id, groupId?, keywords? }`, sin DTO ni conceptos del catálogo. El diálogo sigue admitiendo una tabla TanStack y, sin callback específico, restaura mediante `table.resetColumnVisibility()`.
La integración de EntityTable aporta `onResetColumns`, que vacía sus preferencias locales y permite que useEntityTable resuelva los defaults **actuales**, también cuando las columnas llegan después. No se duplica el estado de TanStack ni se usa un efecto para calcular resultados, grupos o contadores.
## 3. Búsqueda
[normalizeColumnSearch/groupColumnOptions](../packages/rdx-ui/src/lib/entity-column-configuration.ts) filtran por etiqueta o keywords, mediante coincidencia parcial inmediata. Recortan espacios externos, ignoran mayúsculas y tildes y conservan la diferencia entre ñ/n, también con Unicode descompuesto.
La consulta solo vive en el diálogo y se limpia al cerrar desde sus acciones. El botón Limpiar devuelve foco al buscador. Sin coincidencias, el estado explica cómo recuperarse; búsqueda, limpieza, restauración y cierre continúan disponibles.
Filtrar no cambia visibilidad, IDs, sort, filtros del inventario ni preferencias. El contador de coincidencias describe opciones encontradas; el resumen superior sigue describiendo columnas visibles del conjunto completo. Esta utilidad podría reutilizarse, tras definir el contrato de entidades en L03; **V07 no queda resuelto**.
## 4. Agrupación
Los grupos de Activos son:
| ID | Castellano | Inglés | Contenido |
| --- | --- | --- | --- |
| `general` | General | General | Tipo, nombre, plantilla, puesto, notas y acciones |
| `state-location` | Estado y ubicación | Status and location | Estado mostrado/técnico, sede y asignación |
| `audit` | Auditoría | Audit | Fecha de alta y fechas/autores de revisiones |
| `infrastructure` | Infraestructura | Infrastructure | Funciones, interfaces, direcciones IP/MAC y notas de interfaz |
| `characteristics` | Características del catálogo | Catalog characteristics | Campos activos de catálogo e instantáneas conservadas |
La primera declaración de un ID repetido de grupo o metadato prevalece. Las opciones se deduplican por ID; los grupos sin coincidencias desaparecen. Columnas sin grupo o con grupo desconocido siguen disponibles al final, bajo «Otras columnas» cuando se han proporcionado categorías. Sin categorías, hay una lista sencilla sin leyenda artificial.
Los metadatos cambian la organización del selector, nunca la posición de las columnas en la tabla.
## 5. Columnas dinámicas
[assetCharacteristicColumns](../modules/assets/src/web/assets/shared/helpers/asset-characteristic-columns.ts) conserva los IDs `characteristic-<UUID>`, la unión de campos activos con instantáneas y los ordinales para etiquetas iguales de campos diferentes.
Cada campo pertenece al grupo principal estable `characteristics`. No se elige arbitrariamente una plantilla o sección como propietario visual. Keywords reúnen los nombres de todas sus plantillas/secciones, etiquetas históricas, descripción y unidad del campo activo. Un campo compartido mantiene un único checkbox. Los contextos se ordenan y las instantáneas se recorren por ID de activo para mantener una etiqueta histórica determinista cuando no hay definición activa.
El render de celdas continúa utilizando el tipo y la unidad de la instantánea; estos metadatos no cambian valores, sincronización de plantillas ni conservación de campos retirados.
## 6. Visibilidad y obligatoriedad
`hideable:false` y las protecciones de useEntityTable siguen prevaleciendo sobre preferencias incompatibles. En Activos permanecen obligatorias Tipo de activo, Nombre, Puesto de trabajo y Acciones. Se muestran marcadas, deshabilitadas y con «Obligatoria»; una descripción accesible explica que siempre permanecen visibles.
El resumen incluye todas las columnas expuestas, también las obligatorias y Acciones. La columna interna `selection` de pickers se excluye del configurador y su contador: sigue obligatoria en la tabla. Tipo de activo se incluye porque representa información de datos, aunque su cabecera visual sea un icono.
Los totales del catálogo comprobado son **75 columnas: 20 base/adicionales/acciones y 55 características**. Ocho están visibles inicialmente. El contador no representa coincidencias de búsqueda ni filas del inventario.
## 7. Restauración
Restaurar columnas aplica inmediatamente el conjunto inicial definido por `defaultVisible`, la visibilidad predeterminada y las protecciones de cada consumidor. No equivale a mostrar todas las columnas. Se conservan búsqueda y orden del listado y la consulta local del diálogo.
El callback de EntityTable restablece las preferencias a `{}`: cada columna disponible recupera su default actual; las retiradas no reaparecen y las nuevas respetan su definición. Cerrar termina la interacción sin revertir cambios. No se añaden Aplicar/Cancelar ni escrituras de negocio.
## 8. Persistencia
Se mantiene `asset-inventory-columns`, su alcance existente entre compañías del navegador y el formato plano `{ "column-id": boolean }`. La restauración persiste `{}` mediante el escritor existente, sin borrar otras claves ni migrar globalmente preferencias.
Sin preferencia, con valores parciales o con IDs desconocidos, el hook mantiene los defaults y solo presenta columnas reales. Los valores protegidos incompatibles no afectan a la identidad. La prueba de restauración verifica recarga y conservación de una clave ajena. Datos inválidos y storage inaccesible siguen permitiendo operar localmente.
Los listados sin `columnVisibilityStorageKey`, incluidos Recursos y Personas, conservan su configuración local durante su montaje; esta entrega no les añade persistencia.
## 9. Accesibilidad
Se compone FormDialog con Dialog/Base UI, Checkbox, InputGroup y FieldSet/FieldLegend ya instalados. No hay listeners globales ni focus traps propios. El buscador tiene etiqueta accesible, foco inicial y limpieza con retorno de foco. Las categorías tienen leyendas semánticas y las descripciones opcionales están asociadas a su grupo.
Las etiquetas de checkbox excluyen el indicador visual redundante «Obligatoria» de su nombre y lo complementan con una descripción accesible. Tab, Shift+Tab, Enter, Espacio, Escape, cierre y retorno al activador se verifican mediante MCP; el último control conserva foco visible al desplazar las opciones. El diálogo de columnas también se comprueba dentro de un picker sin cerrar el formulario padre.
No se certifica WCAG ni se atribuye a estas pruebas una validación con lector de pantalla.
## 10. Responsive y reproducción inicial
Las medidas previas se tomaron en esta instalación, **antes de cambiar el código funcional**, y se conservan en `before/measurements.json`:
| Resolución | Antes: modal / área con scroll | Después: modal / área de opciones |
| --- | --- | --- |
| 320×640 | 288×608; 468 px de área, lista plana | 288×608; 336 px útiles para opciones, búsqueda y restauración fuera del scroll |
| 1440×900 | 576×868; 728 px de área, lista plana | 576×765; altura exterior estable según el patrón picker |
Antes había 75 checkbox, cero buscadores de texto, cero grupos y foco inicial en Plantilla. Se comprobó ocultar Sede y recargar: `{ "site": false }` se conserva. En móvil había entonces siete visibles por esa elección previa, no por pérdida de un default.
Mediciones de la ejecución final, en píxeles CSS:
| Resolución | Alto exterior | Alto útil de opciones | Filas completas iniciales |
| --- | --- | --- | --- |
| 320×640 | 608.0 | 336 | 6 |
| 390×844 | 812.0 | 540 | 10 |
| 768×1024 | 870.4 | 626 | 12 |
| 1024×768 | 652.8 | 409 | 8 |
| 1366×768 | 652.8 | 409 | 8 |
| 1440×900 | 765.0 | 521 | 10 |
| 1920×1080 | 900.0 | 656 | 13 |
Después se verifican 320×640, 390×844, 768×1024, 1024×768, 1366×768, 1440×900 y 1920×1080. FormBody conserva la cadena flex/min-height y desplazamiento interno. Buscar o vaciar resultados no cambia la altura; la lista no colapsa, ni aparece overflow horizontal global. No se cambian tamaños globales de Dialog.
## 11. Pruebas automatizadas
Se amplían las pruebas existentes de rdx-ui y columnas de activos. Cubren normalización, ñ/n, keywords, grupos opcionales/múltiples/vacíos/desconocidos/repetidos, unicidad y contextos compartidos, defaults, campos nuevos/retirados, storage inválido/bloqueado y aislamiento por clave.
[scripts/columns-ui.test.py](../scripts/columns-ui.test.py) utiliza unittest y el cliente MCP stdio existente. [La fixture React](../packages/rdx-ui/src/column-browser-fixture.tsx) se importa exclusivamente desde Vite durante la prueba, sin rutas ni importación de producción: tres columnas, keywords, campo nuevo y campo retirado.
La suite de navegador comprueba catálogo real, siete resoluciones, visibilidad inmediata, filtrado sin mutaciones, reapertura sin filtro, persistencia/recarga/restauración, inglés/español, teclado, scroll y consumidores sencillos/anidados. La fixture de storage bloqueado afecta solo a la clave de prueba.
## 12. Entorno, evidencias y reproducción Playwright
Instancia única de `scripts/visual-audit.py`, MariaDB desechable `metis_desk_test`, usuario ordinario y login real. [visual-audit-volume.py](../scripts/visual-audit-volume.py) amplía exclusivamente la compañía sintética del informe, mediante API HTTP real con sesión y CSRF válidos. Resultado: **15 compañías; 50 personas, 51 activos y 66 recursos en la principal**. Repetir conserva esos recuentos. Incluye nombres extensos/con tildes, activos de distintas plantillas/estados y recursos activos/inactivos; no simula permisos ni identidades de dominio.
```bash
python3 scripts/visual-audit.py
# En otra terminal, utilizar la ruta privada que imprime el preparador:
PYTHONDONTWRITEBYTECODE=1 METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
python3 scripts/visual-audit-volume.py
PYTHONDONTWRITEBYTECODE=1 METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
python3 scripts/columns-ui.test.py
```
Mantener abierto el preparador; cerrarlo al terminar para retirar solo sus servicios y sesión. El guion de tests no inicia entornos ni crea entidades y bloquea escrituras HTTP de negocio. Las preferencias solo afectan al navegador aislado. No copiar el contenido de `session.json` a Git, logs ni mensajes.
Evidencias ignoradas en [.artifacts/playwright/l02-2/](../.artifacts/playwright/l02-2/): capturas antes/después, snapshots, mediciones por ejecución, consola, comprobaciones técnicas e índice con dimensiones y SHA-256. El índice conserva 134 capturas, incluidas ejecuciones intermedias; `verification.json` identifica la ejecución completa final y las regresiones correctas. Se abren e inspeccionan capturas representativas de móvil/escritorio, inglés, búsqueda, campo compartido, foco y paneles antes de cerrar la entrega. MCP se ejecuta realmente por stdio dentro de la imagen Docker fijada del proyecto; no se instala Chromium en WSL.
Capturas representativas inspeccionadas:
- Antes: [escritorio](../.artifacts/playwright/l02-2/before/columns-1440.png) y [móvil](../.artifacts/playwright/l02-2/before/columns-320.png).
- Después: [escritorio](../.artifacts/playwright/l02-2/after/assets-1440.png) y [320×640](../.artifacts/playwright/l02-2/after/assets-320.png).
- [Inglés en móvil](../.artifacts/playwright/l02-2/after/assets-english-320.png), [campo compartido único](../.artifacts/playwright/l02-2/after/shared-field-one-option.png) y [foco al final del scroll](../.artifacts/playwright/l02-2/after/scroll-focus-last-320.png).
- Regresión: [selección/contraste de activos](../.artifacts/playwright/l02-2/regression-verified/contrast-selected-1920.png) y [panel de recursos en W=984](../.artifacts/playwright/l02-2/regression-verified/resource-capacity-984.png).
## 13. Resultados técnicos y regresiones
Resultados realmente ejecutados:
| Comprobación | Resultado |
| --- | --- |
| Suite MCP L02.2 | **8/8 correctas**, 160,367 s en la ejecución completa final. |
| Siete resoluciones | Correctas; entre 336 y 656 px útiles en el área de opciones. |
| Campo compartido real | Marca, usado por doce plantillas: una opción por ID; localizable por varios contextos. |
| Teclado y scroll | Último checkbox con foco visible, scrollTop 3064; recorrido de 85 pulsaciones Tab sin salir del diálogo tras asentarse Base UI. |
| Persistencia y restauración | Aplicación inmediata, recarga, defaults, claves ajenas intactas, campos nuevos/retirados y storage bloqueado: correctos en navegador. |
| `pnpm --filter @repo/rdx-ui test` | Correcto: 11 archivos. |
| `pnpm --filter @metis/assets test` | Correcto: 27 archivos; repetido tras ampliar el caso de instantáneas históricas. |
| `pnpm --filter @metis/resources test` | Correcto: 11 archivos. |
| Typecheck dirigido rdx-ui, activos, recursos, compañías y web | Correcto en los cinco paquetes. |
| Biome dirigido y ESLint de espaciado | Correctos sobre los 15 archivos propios TS/TSX/JSON modificados/nuevos (ESLint sobre los TS/TSX). |
| `pnpm typecheck` | Falla en vendor `calendar.tsx:165`, incompatibilidad previa de `locale`/exactOptionalPropertyTypes. |
| `pnpm lint` | Falla: 15 errores, un aviso y cuatro informaciones, ajenos al cambio y coincidentes con L02.1. |
| `pnpm check` | Falla: 168 errores, un aviso y cuatro informaciones previos; comprobación dirigida correcta. |
| `pnpm test`, MariaDB temporal independiente | Falla en dos expectativas antiguas de compañías; 7 tareas correctas de 8. No se presenta como suite global superada. |
| `pnpm build` | Correcto, dos tareas; conserva aviso de chunk web superior a 500 kB. |
| Preparación de volumen repetida | Correcta: 15 compañías, 50 personas, 51 activos y 66 recursos; sin duplicados. |
| Regresión MCP V01.3/V01.4, V02 y asistentes | **4/4 correctas**, 158,944 s; contraste, ambos presupuestos/paneles y geometría de asistentes. |
| Consola suite MCP final | Cero errores y advertencias; ninguna escritura HTTP de negocio intentada. |
Las primeras ejecuciones del guion detectaron errores de instrumentación: nombre «Campo» en la fixture de selector, comillas en un localizador y comprobación de foco durante una transferencia transitoria de Base UI. Se corrigieron los tests y se repitió la suite completa. No son defectos funcionales de Metis ni se cuentan como pruebas correctas esos intentos.
Las regresiones antiguas de contraste/capacidad buscaban AUDIT-PORT-01 en la primera página; al preparar 51 activos dejó de estar ahí. Se utiliza la búsqueda real antes de esos dos escenarios. La ejecución conjunta final obtiene **4/4 correctas**, 158,944 s (`browser-regression-verified.log`), con lecturas mediante Locator que esperan la fila/tabla real antes de medir. Las ejecuciones intermedias con errores quedan conservadas y no se presentan como suites correctas. La espera de elementos no modifica la aplicación ni resuelve V12. Las pruebas de asistente y recursos conservan sus operaciones de lectura/cancelación; no guardan cambios.
Durante la revisión se corrigió también la sustitución de parámetros del nuevo preparador de volumen: los placeholders ya no pueden alterar el prefijo de los nombres de compañía. Se normalizaron mediante HTTP las catorce compañías **creadas por esta fixture en la instalación desechable**, sin borrar registros, y se repitió la preparación para verificar los recuentos e idempotencia.
Los fallos globales se comparan con L02.1: `calendar.tsx:165` de vendor en typecheck, 15 errores de lint, 168 de check y las dos expectativas antiguas de compañías («Personas (1)» y aviso de persona sin acceso). No se corrige código ajeno para presentar una suite global verde. El test global usa otra MariaDB nueva y temporal, separada del entorno visual, con cleanup propio.
## 14. Limitaciones y skills
No se introducen reordenación, densidad, alineación semántica, cambios de búsqueda de entidades, restauración completa de contexto ni skeletons. La persistencia sigue compartida entre compañías por el contrato previo; no se transforma en preferencia de servidor ni por usuario autenticado. Los datos voluminosos no constituyen una prueba de carga o rendimiento de producción.
Skill principal: `ux-tables` para personalización y lectura de tablas. Complementarias leídas: `ux-search` para búsqueda secundaria y recuperación, `shadcn` para composición Base UI y componentes instalados, `ux-microcopy` para contadores/obligatoriedad/restauración, `vercel-composition-patterns` para metadatos genéricos sin flags de entidad y `vercel-react-best-practices` para estado derivado sin efectos. Se consultan sus referencias aplicables de estado derivado/eventos, composición y formularios; prevalecen las APIs locales y los patrones Metis.
No se necesita `frontend-design` ni `ui-ux-pro-max` para cambiar jerarquía o resolver otra duda. Los auditores especializados `*-audit` y `docs/orchestration-policy.md` referenciados por las skills no están instalados; no se instalan herramientas ni se presentan esos auditores como ejecutados. No se ejecuta el CLI de shadcn ni se descarga otra versión; se utilizan las primitivas locales sin regenerar vendor.
## 15. Preparación de L02.3
El selector dispone de metadatos opcionales y restauración por consumidor; los listados sin categorías no necesitan migración. L02.3 requiere un encargo propio. **V05, V07 y V12 permanecen pendientes**; no se adelanta reordenación, densidad ni otra entrega del programa.
## Archivos principales
| Área | Archivos modificados o creados |
| --- | --- |
| rdx-ui | `components/entity-list.tsx`, `entity-table-configuration.tsx`, `entity-table-columns-dialog.tsx`; nuevo `lib/entity-column-configuration.ts`; `lib/entity-list.ts`; traducciones `locales/es.json` y `en.json`. |
| Activos | `assets/shared/ui/asset-list.tsx`; helpers `asset-additional-columns.tsx`, `asset-characteristic-columns.ts`; traducciones comunes es/en. |
| Pruebas | `entity-column-visibility.test.tsx`, `asset-columns.test.tsx`; nueva fixture `column-browser-fixture.tsx`; nuevos `scripts/columns-ui.test.py`, `scripts/visual-audit-volume.py`; adaptación acotada de `scripts/visual-audit-ui.test.py` a volumen y lecturas de DOM mediante Locator. |
| Documentación | Este informe, DESIGN.md, auditorías L01/visual, patrón de listados, componentes y Playwright MCP. |
No se modifican `useEntityTable`, el contrato de almacenamiento ni EntityTableContent: sus protecciones, resolución de defaults, persistencia y render existentes permiten implementar la mejora desde la composición. No hay cambios en `packages/shadcn-ui`, package.json, lockfiles o AGENTS.md.
Cierre: se detuvo el preparador propio; retiró sus servicios temporales y eliminó `session.json`. Se verificó la ausencia del archivo de sesión. Las capturas e informes sin credenciales permanecen en `.artifacts/playwright/l02-2/`, excluidos de Git.

View File

@ -49,6 +49,25 @@ El pie opcional está fuera de la tabla y solo aparece cuando existe contenido c
Cuando un listado filtra familias mediante pestañas, utilizar `ScrollableTabsList` de `@repo/rdx-ui` dentro de `Tabs`. La barra mantiene una sola fila limitada al ancho del listado, con desplazamiento horizontal, controles laterales solo cuando sean necesarios y la pestaña activa visible. Mantener `min-w-0` en la raíz y el contenido para que ni las etiquetas ni la tabla ensanchen la página. La selección conserva las reglas de URL, filtros y paginación del listado. Véase [pestañas horizontales desplazables](componentes-de-interfaz.md#pestañas-horizontales-desplazables).
## Configuración de columnas
`EntityTable` reutiliza el diálogo de [L02.2](configuracion-columnas-l02-2.md): búsqueda
por etiqueta/keywords, contador de visibilidad efectiva y Restaurar columnas, con aplicación
inmediata y Cerrar. La búsqueda solo filtra opciones; no modifica columnas, filtros de
datos ni ordenación. Se limpia al terminar la interacción.
El consumidor puede aportar `EntityColumn.groupId`, `keywords` y `columnGroups`
(`id`, `label`, `description?`), en orden de presentación y con textos traducidos.
No imponer grupos a listados sencillos ni duplicar un campo compartido. Conservar IDs
estables y definir un grupo principal determinista; contextos alternativos pueden ser keywords.
Restaurar recupera los defaults actuales del consumidor, no todas las columnas; respeta
`hideable:false` y persiste mediante la clave propia existente, sin afectar otras tablas.
Las obligatorias muestran estado deshabilitado e indicación accesible. El resumen cuenta
columnas expuestas, incluidas obligatorias/acciones, pero excluye `selection`, control interno
del picker. Usar altura estable, búsqueda y cierre accesibles y scroll interno de opciones;
comprobar la superficie operativa en móvil. No se incluye reordenación ni densidad.
## Celdas sin valor
En cualquier listado, tabla, sublistado o PickerList, una celda sin valor muestra exclusivamente un guion largo (`—`, U+2014), cualquiera que sea la causa de la ausencia. No mostrar «Sin indicar», «Sin valor», «Sin especificar», «Sin asignación», «Sin fecha», «Sin logotipo» ni equivalentes en otros idiomas. El signo es común a es/en y no requiere traducción.

View File

@ -207,6 +207,27 @@ Una terminación forzada con SIGKILL o un fallo del daemon puede impedir el clea
La base es desechable, pero una auditoría sigue siendo de lectura salvo que la tarea autorice probar guardados. El conjunto inicial solo contiene una compañía y un activo: otras pantallas pueden necesitar fixtures adicionales explícitas. El usuario ordinario no permite auditar administración de usuarios. La sesión preparada se utiliza con el CLI WSL; no se ha comprobado su carga en extensión o Desktop. Tampoco se ha ejecutado esta modalidad en Docker Desktop o ARM. No hay navegador instalado en WSL ni cambios de seguridad de producción.
## Volumen sintético para pruebas de listados
Con `visual-audit.py` abierto, ampliar su instalación desechable según AGENTS:
```bash
PYTHONDONTWRITEBYTECODE=1 METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
python3 scripts/visual-audit-volume.py
```
Utilizar la ruta privada que imprima el preparador. El script comprueba origen local,
usuario ordinario y la compañía sintética del informe; utiliza HTTP real y CSRF por MCP.
Reutiliza registros por nombre: prepara 15 compañías y, en la principal, al menos 50
personas, 50 activos y 50 recursos. No carga `.env`, no crea usuarios ni guarda cookies
en el informe de volumen. El contenedor del preparador sigue siendo el único propietario
de esos datos, exclusivamente `metis_desk_test`; cerrarlo los retira con su sesión.
La [suite de columnas L02.2](configuracion-columnas-l02-2.md) lee ese entorno, bloquea
escrituras de negocio y verifica la configuración mediante preferencias del navegador
aislado. Esta preparación complementa las fixtures pequeñas de casos límite; no
convierte el recorrido básico del preparador en una prueba de volumen ni de carga.
## Conectividad y origen
La URL del navegador y `APP_ORIGIN` deben coincidir exactamente en esquema, host y puerto. `localhost` y `127.0.0.1` son orígenes distintos aunque alcancen el mismo servicio. Si se usa el origen LAN del proyecto, comprobar ese origen y la configuración de la API; no desactivar sus protecciones.

View File

@ -544,7 +544,14 @@
"ipAddresses": "Interface IP addresses",
"macAddresses": "Interface MAC addresses",
"interfaceNotes": "Interface notes",
"infrastructureError": "Infrastructure data for the optional columns could not be loaded."
"infrastructureError": "Infrastructure data for the optional columns could not be loaded.",
"groups": {
"general": "General",
"stateLocation": "Status and location",
"audit": "Audit",
"infrastructure": "Infrastructure",
"characteristics": "Catalog characteristics"
}
},
"directoryMembership": {
"domain": "Windows domain",

View File

@ -544,7 +544,14 @@
"ipAddresses": "Direcciones IP de interfaces",
"macAddresses": "Direcciones MAC de interfaces",
"interfaceNotes": "Notas de interfaces",
"infrastructureError": "No se pudieron cargar los datos de infraestructura de las columnas opcionales."
"infrastructureError": "No se pudieron cargar los datos de infraestructura de las columnas opcionales.",
"groups": {
"general": "General",
"stateLocation": "Estado y ubicación",
"audit": "Auditoría",
"infrastructure": "Infraestructura",
"characteristics": "Características del catálogo"
}
},
"directoryMembership": {
"domain": "Dominio Windows",

View File

@ -20,18 +20,21 @@ export function assetAdditionalColumns({
const columns: EntityColumn<AssetDTO>[] = [
{
id: "technicalStatus",
groupId: "state-location",
label: t("assetColumns.technicalStatus"),
value: (asset) => t(asset.values.status),
render: (asset) => t(asset.values.status),
},
{
id: "assigned",
groupId: "state-location",
label: t("assetColumns.assigned"),
value: (asset) => asset.assigned,
render: (asset) => t(asset.assigned ? "yes" : "no"),
},
{
id: "notes",
groupId: "general",
label: t("notes"),
value: (asset) => asset.values.notes,
render: (asset) =>
@ -46,6 +49,7 @@ export function assetAdditionalColumns({
return [
{
id: `${event}At`,
groupId: "audit",
label: t(`assetColumns.${event}At`),
value: (asset) => revisionOf(asset)?.recordedAt,
render: (asset) => {
@ -56,6 +60,7 @@ export function assetAdditionalColumns({
},
{
id: `${event}By`,
groupId: "audit",
label: t(`assetColumns.${event}By`),
value: (asset) => revisionOf(asset)?.actorName,
render: (asset) => revisionOf(asset)?.actorName,
@ -64,6 +69,7 @@ export function assetAdditionalColumns({
}),
{
id: "infrastructureRoles",
groupId: "infrastructure",
label: t("assetColumns.roles"),
value: (asset) =>
byAsset
@ -97,6 +103,7 @@ export function assetAdditionalColumns({
return {
id: kind,
groupId: "infrastructure",
label: t(`assetColumns.${kind}`),
value: (asset) => lines(asset).join(" · "),
render: (asset) => {

View File

@ -19,19 +19,40 @@ export function assetCharacteristicColumns({
t: TFunction<"assets">;
}): EntityColumn<AssetDTO>[] {
const fields = new Map<string, Pick<AssetCharacteristic, "fieldId" | "name" | "type" | "unit">>();
const contexts = new Map<string, Set<string>>();
for (const asset of assets) {
function addContext(fieldId: string, ...labels: string[]) {
const current = contexts.get(fieldId) ?? new Set<string>();
for (const label of labels) {
if (label) {
current.add(label);
}
}
contexts.set(fieldId, current);
}
// Las instantáneas retiradas tienen una etiqueta determinista aunque cambie el orden de las filas.
for (const asset of [...assets].sort((first, second) => first.id.localeCompare(second.id))) {
for (const field of asset.specification.fields) {
if (!fields.has(field.fieldId)) {
fields.set(field.fieldId, field);
}
addContext(
field.fieldId,
field.name,
asset.specification.templateName,
asset.specification.sections?.find((section) => section.id === field.sectionId)?.name ?? ""
);
}
}
for (const item of catalog) {
if (item.values.kind === "field" && item.effectiveActive) {
fields.set(item.id, { fieldId: item.id, ...item.values });
addContext(item.id, item.values.name, item.values.description, item.values.unit);
}
}
addTemplateContexts(catalog, addContext);
const collator = new Intl.Collator(locale, { sensitivity: "base", numeric: true });
const sorted = [...fields.values()].sort(
(a, b) => collator.compare(a.name, b.name) || a.fieldId.localeCompare(b.fieldId)
@ -50,6 +71,9 @@ export function assetCharacteristicColumns({
return {
id: `characteristic-${field.fieldId}`,
// Un grupo principal común evita duplicar campos compartidos entre secciones o plantillas.
groupId: "characteristics",
keywords: [...(contexts.get(field.fieldId) ?? [])].sort(collator.compare),
label: labels.filter((item) => item === label).length > 1 ? `${label} (${ordinal})` : label,
defaultVisible: false,
value: (asset) =>
@ -66,6 +90,23 @@ export function assetCharacteristicColumns({
});
}
function addTemplateContexts(
catalog: readonly CatalogItemDTO[],
addContext: (fieldId: string, ...labels: string[]) => void
) {
for (const item of catalog) {
if (item.values.kind === "template") {
for (const field of item.values.fields) {
addContext(
field.fieldId,
item.values.name,
item.values.sections?.find((section) => section.id === field.sectionId)?.name ?? ""
);
}
}
}
}
function formatCharacteristic(
field: AssetCharacteristic | undefined,
value: AssetDTO["values"]["characteristics"][string] | undefined,

View File

@ -7,6 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { CatalogItemSchema } from "../../../../common";
import { InfrastructureSchema } from "../../../../common/infrastructure";
import type { AssetDTO } from "../../../../common/inventory";
import { AssetSchema } from "../../../../common/inventory";
import { assetsEnglish, assetsSpanish } from "../../../shared";
@ -223,3 +224,75 @@ test("columnas generales y de red es/en: fechas y autores propios, IP/MAC por in
);
}
});
test("los campos compartidos conservan un único ID y todos sus contextos de catálogo e instantánea", async () => {
const template = (identity: string, name: string, sectionName: string) =>
CatalogItemSchema.parse({
id: identity,
companyId: null,
builtin: false,
active: true,
effectiveActive: true,
overridden: false,
bindings: [],
values: {
kind: "template",
name,
description: "",
sections: [{ id: id(80), name: sectionName, description: "" }],
fields: [{ fieldId: id(10), required: false, sectionId: id(80) }],
},
});
const catalog = [
template(id(81), "Servidor", "Memoria"),
template(id(82), "Sobremesa", "Rendimiento"),
];
const t = await translator("es");
const columns = assetCharacteristicColumns({ assets: [asset, asset], catalog, locale: "es", t });
const ram = columns.find((column) => column.id === `characteristic-${id(10)}`);
assert.equal(columns.filter((column) => column.id === ram?.id).length, 1);
assert.equal(ram?.groupId, "characteristics");
for (const keyword of ["Servidor", "Sobremesa", "Memoria", "Rendimiento", "Portátil", "RAM"]) {
assert.ok(ram?.keywords?.includes(keyword), keyword);
}
assert.deepEqual(
assetCharacteristicColumns({
assets: [asset],
catalog: [...catalog].reverse(),
locale: "es",
t,
}).map((column) => [column.id, column.label, column.keywords]),
columns.map((column) => [column.id, column.label, column.keywords])
);
const historicalAsset = {
...asset,
id: id(88),
specification: {
...asset.specification,
templateName: "Plantilla retirada",
fields: asset.specification.fields.map((field) =>
field.fieldId === id(10) ? { ...field, name: "Memoria histórica" } : field
),
},
};
const historicalColumns = (items: readonly AssetDTO[]) =>
assetCharacteristicColumns({ assets: items, catalog: [], locale: "es", t }).map((column) => [
column.id,
column.label,
column.keywords,
]);
assert.deepEqual(
historicalColumns([asset, historicalAsset]),
historicalColumns([historicalAsset, asset])
);
assert.ok(
assetCharacteristicColumns({ assets: [historicalAsset, asset], catalog: [], locale: "es", t })
.find((column) => column.id === `characteristic-${id(10)}`)
?.keywords?.includes("Memoria histórica")
);
});

View File

@ -104,10 +104,18 @@ export function AssetList({
value={templateId ?? "all"}
>
<EntityTable
columnGroups={[
{ id: "general", label: t("assetColumns.groups.general") },
{ id: "state-location", label: t("assetColumns.groups.stateLocation") },
{ id: "audit", label: t("assetColumns.groups.audit") },
{ id: "infrastructure", label: t("assetColumns.groups.infrastructure") },
{ id: "characteristics", label: t("assetColumns.groups.characteristics") },
]}
columns={[
...leadingColumns,
{
id: "icon",
groupId: "general",
label: t("assetType"),
hideable: false,
render: (asset) => {
@ -123,6 +131,7 @@ export function AssetList({
},
{
id: "name",
groupId: "general",
label: t("name"),
value: (asset) => asset.values.name,
hideable: false,
@ -150,6 +159,7 @@ export function AssetList({
},
{
id: "template",
groupId: "general",
label: t("template"),
value: (asset) => asset.specification.templateName,
render: (asset) => (
@ -158,6 +168,7 @@ export function AssetList({
},
{
id: "state",
groupId: "state-location",
label: t("status"),
value: (asset) => t(asset.displayStatus),
render: (asset) => (
@ -168,10 +179,19 @@ export function AssetList({
</span>
),
},
{ id: "site", label: t("site"), value: siteName, render: siteName },
...extraColumns.filter((column) => column.id !== "actions"),
{
id: "site",
groupId: "state-location",
label: t("site"),
value: siteName,
render: siteName,
},
...extraColumns
.filter((column) => column.id !== "actions")
.map((column) => ({ ...column, groupId: column.groupId ?? "general" })),
{
id: "registeredOn",
groupId: "audit",
label: t("registeredOn"),
value: (asset) => asset.values.registeredOn ?? "",
render: (asset) => {
@ -186,7 +206,9 @@ export function AssetList({
},
...assetAdditionalColumns({ infrastructure, locale: i18n.language, t }),
...assetCharacteristicColumns({ assets, catalog, locale: i18n.language, t }),
...extraColumns.filter((column) => column.id === "actions"),
...extraColumns
.filter((column) => column.id === "actions")
.map((column) => ({ ...column, groupId: "general" })),
]}
columnVisibilityStorageKey={columnVisibilityStorageKey}
emptyMessage={t("noAssets")}

View File

@ -0,0 +1,67 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { createRoot } from "react-dom/client";
import type { EntityColumn } from "./components/entity-list";
import { EntityTable } from "./components/entity-list";
/** Fixture importada solo por Vite desde las pruebas MCP; no registra rutas ni endpoints. */
function ColumnBrowserFixture({ locale }: { locale: string }) {
const [catalog, setCatalog] = useState("initial");
const english = locale.startsWith("en");
const columns: EntityColumn<{ id: string; name: string }>[] = [
{
id: "name",
label: english ? "Name" : "Nombre",
hideable: false,
value: (item) => item.name,
render: (item) => item.name,
},
...(catalog === "removed"
? []
: [
{
id: "detail",
label: english ? "Notes" : "Notas",
keywords: ["revisión técnica"],
defaultVisible: false,
render: () => "Detalles",
},
]),
{ id: "year", label: english ? "Year" : "Año", value: () => 2026, render: () => 2026 },
...(catalog === "new"
? [
{
id: "late",
label: english ? "New field" : "Campo nuevo",
defaultVisible: false,
render: () => "Campo posterior",
},
]
: []),
];
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex gap-2">
<Button onClick={() => setCatalog("new")}>Añadir campo</Button>
<Button onClick={() => setCatalog("removed")}>Retirar campo</Button>
</div>
<EntityTable
columns={columns}
columnVisibilityStorageKey="columns-test-current"
items={[{ id: "one", name: "Identidad" }]}
locale={locale}
title={english ? "Sample" : "Muestra"}
/>
</div>
);
}
export function mountColumnFixture(target: HTMLElement, locale: string) {
const root = createRoot(target);
root.render(<ColumnBrowserFixture locale={locale} />);
return root;
}

View File

@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import { useId } from "react";
import { useEntityTable } from "../hooks/use-entity-table";
import type { EntityColumnGroup, EntityColumnMetadata } from "../lib/entity-column-configuration";
import { entityListLabels } from "../lib/entity-list";
import { EntityListPagination } from "./entity-list-controls";
@ -49,7 +50,7 @@ export function EntityListHeader({
);
}
export type EntityColumn<T> = {
export type EntityColumn<T> = EntityColumnMetadata & {
id: string;
label: string;
render: (item: T) => ReactNode;
@ -73,6 +74,7 @@ export function EntityTable<T extends { id: string }>({
onClearFilters,
filterKey = "",
columnVisibilityStorageKey,
columnGroups,
loading = false,
unavailable = false,
emptyMessage,
@ -93,6 +95,7 @@ export function EntityTable<T extends { id: string }>({
onClearFilters?: () => void;
filterKey?: string;
columnVisibilityStorageKey?: string | undefined;
columnGroups?: readonly EntityColumnGroup[];
loading?: boolean;
unavailable?: boolean;
emptyMessage?: string;
@ -140,7 +143,10 @@ export function EntityTable<T extends { id: string }>({
const configuration = (
<EntityTableConfiguration
columnGroups={columnGroups}
columnMetadata={columns}
locale={locale}
onResetColumns={() => table.setColumnVisibility({})}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
presentation={layout === "fill" ? "picker" : "full"}
table={table}

View File

@ -1,49 +1,173 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { Field, FieldLabel, FieldLegend, FieldSet } from "@repo/shadcn-ui/components/field";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@repo/shadcn-ui/components/input-group";
import type { Table as TableModel } from "@tanstack/react-table";
import { useId } from "react";
import { useId, useState } from "react";
import type {
EntityColumnGroup,
EntityColumnMetadata,
EntityColumnOption,
} from "../lib/entity-column-configuration";
import { groupColumnOptions } from "../lib/entity-column-configuration";
import { entityListLabels } from "../lib/entity-list";
import { SearchIcon, XIcon } from "../lib/icons";
import { FormDialog } from "./form-dialog";
import { FormBody, FormDialog } from "./form-dialog";
export function EntityTableColumnsDialog<T>({
table,
locale,
open,
onOpenChange,
columnGroups = [],
columnMetadata = [],
onResetColumns,
}: {
table: TableModel<T>;
locale: string;
open: boolean;
onOpenChange: (open: boolean) => void;
columnGroups?: readonly EntityColumnGroup[] | undefined;
columnMetadata?: readonly (EntityColumnMetadata & { id: string })[] | undefined;
onResetColumns?: (() => void) | undefined;
}) {
const id = useId();
const labels = entityListLabels(locale);
const [search, setSearch] = useState("");
const metadata = new Map([...columnMetadata].reverse().map((column) => [column.id, column]));
// La selección es un control interno del picker, no una columna de datos configurable.
const options: EntityColumnOption[] = table
.getAllLeafColumns()
.filter((column) => column.id !== "selection")
.map((column) => ({
...metadata.get(column.id),
id: column.id,
label: String(column.columnDef.header),
visible: column.getIsVisible(),
hideable: column.getCanHide(),
}));
const groups = groupColumnOptions(options, columnGroups, search);
const visible = options.filter((column) => column.visible).length;
const matches = groups.reduce((total, group) => total + group.options.length, 0);
function changeOpen(nextOpen: boolean) {
setSearch("");
onOpenChange(nextOpen);
}
return (
<FormDialog
cancelLabel={locale.startsWith("en") ? "Close" : "Cerrar"}
onOpenChange={onOpenChange}
cancelLabel={labels.close}
layout="picker"
onOpenChange={changeOpen}
open={open}
title={labels.columns}
>
<div className="space-y-3">
{table.getAllLeafColumns().map((column) => (
<label
className="flex items-center gap-2"
htmlFor={`${id}-column-${column.id}`}
key={column.id}
>
<Checkbox
checked={column.getIsVisible()}
disabled={!column.getCanHide()}
id={`${id}-column-${column.id}`}
onCheckedChange={(checked) => column.toggleVisibility(checked === true)}
<div className="flex shrink-0 flex-col gap-3 px-1 pb-3">
<Field>
<FieldLabel className="sr-only" htmlFor={`${id}-search`}>
{labels.searchColumns}
</FieldLabel>
<InputGroup>
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
data-quick-search=""
id={`${id}-search`}
onChange={(event) => setSearch(event.target.value)}
placeholder={labels.searchColumns}
value={search}
/>
{String(column.columnDef.header)}
</label>
))}
<InputGroupAddon align="inline-end">
<InputGroupButton
aria-label={labels.clearColumnSearch}
disabled={!search}
onClick={() => {
setSearch("");
document.getElementById(`${id}-search`)?.focus();
}}
size="icon-sm"
>
<XIcon aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
<div className="flex flex-wrap items-center justify-between gap-2">
<p aria-live="polite" className="text-sm text-muted-foreground">
{labels.visibleColumns(visible, options.length)}
</p>
<Button
onClick={() => (onResetColumns ? onResetColumns() : table.resetColumnVisibility())}
size="sm"
type="button"
variant="outline"
>
{labels.restoreColumns}
</Button>
</div>
</div>
<FormBody>
<span className="sr-only" id={`${id}-required`}>
{labels.requiredColumnHelp}
</span>
{search.trim() && (
<p aria-live="polite" className="text-sm text-muted-foreground">
{labels.matchingColumns(matches)}
</p>
)}
{matches === 0 && <p role="status">{labels.noMatchingColumns}</p>}
{groups.map(({ group, options: groupOptions }) => (
<FieldSet
aria-describedby={group?.description ? `${id}-description-${group.id}` : undefined}
className="shrink-0 gap-1 pb-3"
key={group ? `group-${group.id}` : "ungrouped"}
>
{(group || columnGroups.length > 0) && (
<FieldLegend variant="label">{group?.label ?? labels.otherColumns}</FieldLegend>
)}
{group?.description && (
<p
className="mb-2 text-sm text-muted-foreground"
id={`${id}-description-${group.id}`}
>
{group.description}
</p>
)}
{groupOptions.map((column) => (
<label
className="flex min-h-9 items-center gap-3 rounded px-1 py-1"
htmlFor={`${id}-column-${column.id}`}
key={column.id}
>
<Checkbox
aria-describedby={column.hideable ? undefined : `${id}-required`}
checked={column.visible}
disabled={!column.hideable}
id={`${id}-column-${column.id}`}
onCheckedChange={(checked) =>
table.getColumn(column.id)?.toggleVisibility(checked === true)
}
/>
<span className="min-w-0 flex-1 break-words">{column.label}</span>
{!column.hideable && (
<span aria-hidden="true" className="text-xs text-muted-foreground">
{labels.requiredColumn}
</span>
)}
</label>
))}
</FieldSet>
))}
</FormBody>
</FormDialog>
);
}

View File

@ -2,6 +2,7 @@ import { Button } from "@repo/shadcn-ui/components/button";
import type { Table as TableModel } from "@tanstack/react-table";
import { useId, useState } from "react";
import type { EntityColumnGroup, EntityColumnMetadata } from "../lib/entity-column-configuration";
import { entityListLabels } from "../lib/entity-list";
import { EntityTableColumnsDialog } from "./entity-table-columns-dialog";
@ -12,11 +13,17 @@ export function EntityTableConfiguration<T>({
locale,
onSort,
presentation = "full",
columnGroups,
columnMetadata,
onResetColumns,
}: {
table: TableModel<T>;
locale: string;
onSort: () => void;
presentation?: "full" | "picker";
columnGroups?: readonly EntityColumnGroup[] | undefined;
columnMetadata?: readonly (EntityColumnMetadata & { id: string })[] | undefined;
onResetColumns?: (() => void) | undefined;
}) {
const id = useId();
const labels = entityListLabels(locale);
@ -68,8 +75,11 @@ export function EntityTableConfiguration<T>({
</div>
</div>{" "}
<EntityTableColumnsDialog
columnGroups={columnGroups}
columnMetadata={columnMetadata}
locale={locale}
onOpenChange={setColumnsOpen}
onResetColumns={onResetColumns}
open={columnsOpen}
table={table}
/>

View File

@ -4,10 +4,12 @@ import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { EntityTable } from "./components/entity-list";
import { groupColumnOptions, normalizeColumnSearch } from "./lib/entity-column-configuration";
import {
readColumnVisibility,
writeColumnVisibility,
} from "./lib/entity-column-visibility-storage";
import { entityListLabels } from "./lib/entity-list";
function withStorage(run: (values: Map<string, string>) => void) {
const original = Object.getOwnPropertyDescriptor(globalThis, "window");
@ -153,3 +155,113 @@ test("almacenamiento bloqueado o sin cuota permite seguir utilizando la selecci
}
}
});
test("buscar columnas ignora tildes y mayúsculas, conserva ñ y no modifica visibilidad", () => {
const options = [
{
id: "audit",
label: "Última modificación",
keywords: ["Revisión técnica"],
visible: false,
hideable: true,
},
{ id: "year", label: "Año", visible: true, hideable: false },
{ id: "anus", label: "Ano", visible: false, hideable: true },
];
const initial = structuredClone(options);
for (const query of ["Última modificación", "modific", " ULTIMA ", "revision", "TECNICA"]) {
assert.deepEqual(
groupColumnOptions(options, [], query).flatMap((group) =>
group.options.map((column) => column.id)
),
["audit"]
);
}
assert.equal(normalizeColumnSearch(" AÑÓ "), "año");
assert.equal(normalizeColumnSearch("AN\u0303O"), "año");
assert.deepEqual(
groupColumnOptions(options, [], "año").flatMap((group) =>
group.options.map((column) => column.id)
),
["year"]
);
assert.deepEqual(
groupColumnOptions(options, [], "ano").flatMap((group) =>
group.options.map((column) => column.id)
),
["anus"]
);
assert.deepEqual(groupColumnOptions(options, [], "nada"), []);
assert.equal(groupColumnOptions(options, [], " ")[0]?.options.length, 3);
assert.deepEqual(options, initial);
});
test("los grupos conservan orden, omiten vacíos y deduplican IDs sin perder columnas sin grupo", () => {
const options = [
{ id: "one", label: "Uno", groupId: "second", visible: true, hideable: false },
{ id: "two", label: "Dos", groupId: "first", visible: false, hideable: true },
{ id: "one", label: "Duplicada", groupId: "first", visible: false, hideable: true },
{ id: "loose", label: "Suelta", visible: true, hideable: true },
{ id: "unknown", label: "Sin definición", groupId: "unknown", visible: true, hideable: true },
];
const groups = [
{ id: "first", label: "Primero" },
{ id: "second", label: "Segundo" },
{ id: "first", label: "Otra etiqueta" },
{ id: "empty", label: "Vacío" },
];
const result = groupColumnOptions(options, groups, "");
assert.deepEqual(
result.map((entry) => entry.group?.label),
["Primero", "Segundo", undefined]
);
assert.deepEqual(
result.map((entry) => entry.options.map((option) => option.id)),
[["two"], ["one"], ["loose", "unknown"]]
);
assert.equal(groupColumnOptions(options, groups, "Dos").length, 1);
assert.equal(groupColumnOptions(options, [], "").length, 1);
assert.equal(groupColumnOptions(options, [{ id: "first", label: "Primero" }], "Dos").length, 1);
assert.deepEqual(groupColumnOptions([], groups, ""), []);
});
test("restaurar preferencias vacías recupera defaults actuales sin tocar otras tablas", () => {
withStorage((storage) => {
writeColumnVisibility("other", { optional: true });
writeColumnVisibility("assets", { name: false, detail: true, retired: true });
writeColumnVisibility("assets", {});
const html = renderToStaticMarkup(
<EntityTable
columns={[
{ id: "name", label: "Nombre", hideable: false, render: () => "Identidad" },
{
id: "detail",
label: "Detalle",
defaultVisible: false,
render: () => "Opcional oculta",
},
{ id: "new", label: "Nueva", defaultVisible: true, render: () => "Nueva visible" },
]}
columnVisibilityStorageKey="assets"
items={[{ id: "one" }]}
title="Activos"
/>
);
assert.ok(html.includes("Identidad"));
assert.ok(html.includes("Nueva visible"));
assert.equal(html.includes("Opcional oculta"), false);
assert.equal(html.includes("retired"), false);
assert.equal(storage.get("assets"), "{}");
assert.deepEqual(readColumnVisibility("other"), { optional: true });
});
});
test("los contadores distinguen visibilidad, total y coincidencias en es/en", () => {
assert.equal(entityListLabels("es").visibleColumns(8, 75), "8 columnas visibles de 75");
assert.equal(entityListLabels("en").visibleColumns(1, 3), "1 visible column of 3");
assert.equal(entityListLabels("es").matchingColumns(1), "1 coincidencia");
assert.equal(entityListLabels("en").matchingColumns(0), "0 matches");
});

View File

@ -0,0 +1,66 @@
export type EntityColumnMetadata = {
groupId?: string;
keywords?: readonly string[];
};
export type EntityColumnGroup = {
id: string;
label: string;
description?: string;
};
export type EntityColumnOption = EntityColumnMetadata & {
id: string;
label: string;
visible: boolean;
hideable: boolean;
};
/** Normaliza tildes sin equiparar ñ y n, incluidas sus representaciones Unicode descompuestas. */
export function normalizeColumnSearch(value: string): string {
return value
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/n\u0303/g, "ñ")
.replace(/\p{M}/gu, "");
}
/** Los IDs conservan identidad; la primera declaración de un grupo u opción prevalece. */
export function groupColumnOptions(
options: readonly EntityColumnOption[],
groups: readonly EntityColumnGroup[],
search: string
) {
const term = normalizeColumnSearch(search);
const uniqueOptions = new Map<string, EntityColumnOption>();
const uniqueGroups = new Map<string, EntityColumnGroup>();
for (const group of groups) {
if (!uniqueGroups.has(group.id)) {
uniqueGroups.set(group.id, group);
}
}
for (const option of options) {
if (!uniqueOptions.has(option.id)) {
uniqueOptions.set(option.id, option);
}
}
const matches = [...uniqueOptions.values()].filter((option) =>
[option.label, ...(option.keywords ?? [])].some((value) =>
normalizeColumnSearch(value).includes(term)
)
);
const grouped = [...uniqueGroups.values()].map((group) => ({
group,
options: matches.filter((option) => option.groupId === group.id),
}));
const ungrouped = matches.filter(
(option) => !(option.groupId && uniqueGroups.has(option.groupId))
);
return [
...grouped.filter((group) => group.options.length > 0),
...(ungrouped.length ? [{ group: undefined, options: ungrouped }] : []),
];
}

View File

@ -8,6 +8,15 @@ export function entityListLabels(locale = "es") {
return {
...labels,
visibleColumns: (count: number, total: number) =>
(count === 1 ? labels.visibleColumn : labels.visibleColumns)
.replace("{{count}}", String(count))
.replace("{{total}}", String(total)),
matchingColumns: (count: number) =>
(count === 1 ? labels.matchingColumn : labels.matchingColumns).replace(
"{{count}}",
String(count)
),
range: (
from: number,
to: number,

View File

@ -5,6 +5,17 @@
"placeholder": "Search by name…",
"clear": "Clear filters",
"columns": "Columns",
"searchColumns": "Search columns…",
"clearColumnSearch": "Clear column search",
"restoreColumns": "Restore columns",
"requiredColumn": "Required",
"requiredColumnHelp": "This column is required and always remains visible.",
"otherColumns": "Other columns",
"visibleColumn": "{{count}} visible column of {{total}}",
"visibleColumns": "{{count}} visible columns of {{total}}",
"matchingColumn": "{{count}} match",
"matchingColumns": "{{count}} matches",
"noMatchingColumns": "No matching columns. Change or clear the search.",
"sort": "Sort by",
"initial": "Initial order",
"ascending": "Ascending",

View File

@ -5,6 +5,17 @@
"placeholder": "Buscar por nombre…",
"clear": "Limpiar filtros",
"columns": "Columnas",
"searchColumns": "Buscar columnas…",
"clearColumnSearch": "Limpiar búsqueda de columnas",
"restoreColumns": "Restaurar columnas",
"requiredColumn": "Obligatoria",
"requiredColumnHelp": "Esta columna es obligatoria y siempre permanece visible.",
"otherColumns": "Otras columnas",
"visibleColumn": "{{count}} columna visible de {{total}}",
"visibleColumns": "{{count}} columnas visibles de {{total}}",
"matchingColumn": "{{count}} coincidencia",
"matchingColumns": "{{count}} coincidencias",
"noMatchingColumns": "No se encuentran columnas. Cambia o limpia la búsqueda.",
"sort": "Ordenar por",
"initial": "Orden inicial",
"ascending": "Ascendente",

297
scripts/columns-ui.test.py Normal file
View File

@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""Regresión L02.2 mediante Playwright MCP y la sesión sintética del preparador.
No inicia servicios ni escribe entidades; las preferencias de columnas son locales.
Preparar volumen con visual-audit-volume.py antes de ejecutar esta suite.
"""
import contextlib
import json
import os
from pathlib import Path
import runpy
import tomllib
import unittest
from urllib.parse import quote, urlparse
ROOT = Path(__file__).resolve().parent.parent
AUDIT = runpy.run_path(str(ROOT / "scripts/visual-audit.py"))
SIZES = [(320, 640), (390, 844), (768, 1024), (1024, 768), (1366, 768), (1440, 900), (1920, 1080)]
class ColumnsUiTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
value = os.environ.get("METIS_PLAYWRIGHT_STORAGE_STATE")
if not value:
raise unittest.SkipTest("Requiere sesión sintética activa de visual-audit.py.")
state = (ROOT / value).resolve()
if not state.is_relative_to(ROOT / ".artifacts/playwright") or state.stat().st_mode & 0o077:
raise RuntimeError("Requiere sesión privada del preparador.")
report = json.loads((state.parent / "report.json").read_text())
if urlparse(report["origin"]).hostname != "localhost" or report.get("ordinaryUser") is not True:
raise RuntimeError("Requiere instalación sintética local ordinaria.")
cls.origin = report["origin"]
cls.company = "/companies/" + report["companyId"]
cls.output = ROOT / ".artifacts/playwright/l02-2/after"
cls.output.mkdir(parents=True, exist_ok=True)
cls.results = []
cls.measurements = cls.output / ("measurements-" + str(os.getpid()) + ".json")
cls.stack = contextlib.ExitStack()
cls.addClassCleanup(cls.stack.close)
config = tomllib.loads((ROOT / ".codex/config.toml").read_text())["mcp_servers"]["playwright"]
cls.client = cls.stack.enter_context(AUDIT["browser"](config, state))
if cls.code("return (await page.request.get(" + json.dumps(cls.origin + "/api/companies") + ")).status();") != 200:
raise RuntimeError("La sesión está caducada.")
cls.code("page.blockedWrites=[];await page.route('**/api/**',r=>{if(['GET','HEAD','OPTIONS'].includes(r.request().method()))return r.continue();page.blockedWrites.push(r.request().method());return r.abort()});return true;")
@classmethod
def code(cls, body):
result = AUDIT["tool"](cls.client, "browser_run_code_unsafe", {"code": "async(page)=>{" + body + "}"})
return AUDIT["result_json"](result)
@classmethod
def tearDownClass(cls):
console = AUDIT["tool"](cls.client, "browser_console_messages", {"level": "warning"})
(cls.output / ("console-" + str(os.getpid()) + ".json")).write_text(json.dumps(console, ensure_ascii=False, indent=2) + "\n")
(cls.output / ("blocked-writes-" + str(os.getpid()) + ".json")).write_text(json.dumps(cls.code("return page.blockedWrites;")) + "\n")
def settle(self):
self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))});return true;")
def navigate(self, section="assets"):
self.code("await page.evaluate(()=>window.columnTestRoot?.unmount());return true;")
AUDIT["tool"](self.client, "browser_navigate", {"url": self.origin + self.company + "/" + section})
self.code("await page.locator('[data-slot=workspace]').waitFor();return true;")
self.settle()
def resize(self, width, height):
AUDIT["tool"](self.client, "browser_resize", {"width": width, "height": height})
self.settle()
def open(self, english=False):
self.code("await page.getByRole('button',{name:" + json.dumps("Columns" if english else "Columnas") + ",exact:true}).last().click();await page.getByRole('dialog').last().getByRole('textbox').waitFor();return true;")
self.settle()
def close(self):
self.code("await page.keyboard.press('Escape');return true;")
self.settle()
def search(self, value):
self.code("await page.getByRole('dialog').last().getByRole('textbox').fill(" + json.dumps(value) + ");return true;")
self.settle()
def record(self, name, result):
self.results.append({"scenario": name, "result": result})
self.measurements.write_text(json.dumps(self.results, ensure_ascii=False, indent=2) + "\n")
def capture(self, name):
AUDIT["capture"](self.client, self.output, name)
snapshot = AUDIT["tool"](self.client, "browser_snapshot", {})
(self.output / (name + ".txt")).write_text("\n".join(item["text"] for item in snapshot["content"] if item.get("type") == "text"))
def geometry(self):
return self.code("""return page.getByRole('dialog').last().evaluate(d=>{
const b=d.getBoundingClientRect(),s=d.querySelector('[data-slot=form-body]'),r=s.getBoundingClientRect(),f=d.querySelector('[data-slot=dialog-footer]').getBoundingClientRect(),q=d.querySelector('input[data-quick-search]').getBoundingClientRect();
const options=[...d.querySelectorAll('label:has([role=checkbox])')];
return {viewport:[innerWidth,innerHeight],height:b.height,width:b.width,scrollClient:s.clientHeight,scrollHeight:s.scrollHeight,visible:d.querySelectorAll('[role=checkbox][aria-checked=true]').length,columns:options.length,groups:[...d.querySelectorAll('legend')].map(e=>e.textContent),protected:[...d.querySelectorAll('[role=checkbox][aria-disabled=true]')].map(e=>e.parentElement.textContent),focusSearch:document.activeElement.hasAttribute('data-quick-search'),searchVisible:q.top>=b.top&&q.bottom<=b.bottom,footerVisible:f.top>=b.top&&f.bottom<=b.bottom,fullOptions:options.filter(e=>{const x=e.getBoundingClientRect();return x.top>=r.top&&x.bottom<=r.bottom}).length,overflow:document.documentElement.scrollWidth>innerWidth,preference:localStorage.getItem('asset-inventory-columns')};
});""")
def fixture(self, locale="es"):
self.navigate()
module = "/@fs/" + quote(str(ROOT / "packages/rdx-ui/src/column-browser-fixture.tsx"), safe="/")
self.code("await page.evaluate(async({module,locale})=>{const target=document.createElement('div');document.body.append(target);window.columnTestRoot=(await import(module)).mountColumnFixture(target,locale)},{module:" + json.dumps(module) + ",locale:" + json.dumps(locale) + "});return true;")
self.settle()
def test_01_actual_catalog_and_resolutions(self):
self.navigate()
self.code("await page.evaluate(()=>localStorage.removeItem('asset-inventory-columns'));await page.reload();return true;")
self.open()
for width, height in SIZES:
self.resize(width, height)
result = self.geometry()
self.record("assets-" + str(width), result)
self.assertEqual(result["columns"], 75)
self.assertEqual(result["visible"], 8)
self.assertEqual(len(result["groups"]), 5)
self.assertEqual(len(result["protected"]), 4)
self.assertTrue(result["searchVisible"] and result["footerVisible"])
self.assertGreater(result["scrollClient"], 160)
self.assertGreaterEqual(result["fullOptions"], 2)
self.assertFalse(result["overflow"])
self.capture("assets-" + str(width))
self.close()
def test_02_search_does_not_change_visibility(self):
self.resize(320, 640)
self.navigate()
self.open()
before = self.geometry()
for query, count in [(" ULTIMA MODIFICACION ", 1), ("Portátil", None), ("zzzz-no-existe", 0)]:
self.search(query)
result = self.geometry()
self.record("search-" + query.strip(), result)
if count is not None:
self.assertEqual(result["columns"], count)
else:
self.assertGreater(result["columns"], 0)
self.assertEqual(result["groups"], ["Características del catálogo"])
self.assertEqual(result["preference"], before["preference"])
self.assertEqual(result["height"], before["height"])
self.capture("search-" + ("none" if count == 0 else "keyword" if count is None else "accent"))
self.code("await page.getByRole('button',{name:'Limpiar búsqueda de columnas',exact:true}).click();return true;")
self.assertEqual(self.geometry()["columns"], 75)
self.assertTrue(self.geometry()["focusSearch"])
self.search("estado")
self.close()
self.open()
self.assertEqual(self.code("return page.getByRole('dialog').getByRole('textbox').inputValue();"), "")
self.close()
def test_03_preferences_restore_and_reload(self):
self.resize(1440, 900)
self.navigate()
self.code("await page.evaluate(()=>{localStorage.setItem('asset-inventory-columns',JSON.stringify({name:false,site:false,notes:true,unknown:true}));localStorage.setItem('columns-unrelated',JSON.stringify({keep:false}))});await page.reload();return true;")
self.open()
self.assertTrue(self.code("return page.getByRole('checkbox',{name:'Nombre',exact:true}).isChecked();"))
self.assertFalse(self.code("return page.getByRole('checkbox',{name:'Sede',exact:true}).isChecked();"))
self.assertTrue(self.code("return page.getByRole('checkbox',{name:'Notas',exact:true}).isChecked();"))
self.search("Sede")
self.code("await page.getByRole('checkbox',{name:'Sede',exact:true}).check();return true;")
self.close()
self.assertEqual(self.code("return page.getByRole('columnheader',{name:'Sede',exact:true}).count();"), 1)
self.code("await page.reload();return true;")
self.open()
self.assertTrue(self.code("return page.getByRole('checkbox',{name:'Sede',exact:true}).isChecked();"))
self.code("await page.getByRole('button',{name:'Restaurar columnas',exact:true}).click();return true;")
self.record("restored", self.geometry())
self.assertEqual(self.geometry()["visible"], 8)
self.assertEqual(self.code("return page.evaluate(()=>localStorage.getItem('asset-inventory-columns'));"), "{}")
self.assertEqual(self.code("return page.evaluate(()=>localStorage.getItem('columns-unrelated'));"), '{"keep":false}')
self.capture("restored-desktop")
self.close()
self.code("await page.reload();return true;")
self.open()
self.assertEqual(self.geometry()["visible"], 8)
self.assertFalse(self.code("return page.getByRole('checkbox',{name:'Notas',exact:true}).isChecked();"))
self.close()
def test_04_three_columns_dynamic_and_storage_blocked(self):
self.resize(390, 844)
self.fixture()
self.open()
self.assertEqual(self.geometry()["columns"], 3)
self.assertEqual(self.geometry()["groups"], [])
self.assertEqual(self.geometry()["visible"], 2)
self.capture("simple-three-columns")
self.search("revision TECNICA")
self.assertEqual(self.geometry()["columns"], 1)
self.code("await page.getByRole('checkbox',{name:'Notas',exact:true}).check();return true;")
self.close()
self.code("await page.getByRole('button',{name:'Añadir campo',exact:true}).click();return true;")
self.open()
self.assertEqual(self.geometry()["columns"], 4)
self.assertFalse(self.code("return page.getByRole('checkbox',{name:'Campo nuevo',exact:true}).isChecked();"))
self.code("await page.getByRole('checkbox',{name:'Campo nuevo',exact:true}).check();await page.getByRole('button',{name:'Restaurar columnas',exact:true}).click();return true;")
self.assertEqual(self.geometry()["visible"], 2)
self.close()
self.code("await page.getByRole('button',{name:'Retirar campo',exact:true}).click();return true;")
self.open()
self.assertEqual(self.geometry()["columns"], 2)
self.close()
self.code("await page.addInitScript(()=>{const get=Storage.prototype.getItem,set=Storage.prototype.setItem;Storage.prototype.getItem=function(k){if(k==='columns-test-current')throw Error('blocked');return get.call(this,k)};Storage.prototype.setItem=function(k,v){if(k==='columns-test-current')throw Error('blocked');return set.call(this,k,v)}});return true;")
self.fixture()
self.open()
self.code("await page.getByRole('checkbox',{name:'Notas',exact:true}).check();await page.getByRole('button',{name:'Restaurar columnas',exact:true}).click();return true;")
self.assertEqual(self.geometry()["visible"], 2)
self.record("storage-blocked", self.geometry())
self.close()
def test_05_keyboard_focus_and_english(self):
self.resize(320, 640)
self.fixture("en")
self.open(english=True)
self.assertTrue(self.geometry()["focusSearch"])
self.code("await page.keyboard.press('Tab');return true;")
self.assertTrue(self.code("return page.evaluate(()=>document.activeElement.textContent==='Restore columns');"))
self.code("await page.keyboard.press('Enter');await page.keyboard.press('Tab');await page.keyboard.press('Space');return true;")
self.assertEqual(self.geometry()["visible"], 3)
self.code("await page.keyboard.press('Shift+Tab');await page.keyboard.press('Enter');return true;")
self.assertEqual(self.geometry()["visible"], 2)
self.capture("english-keyboard-320")
self.close()
self.assertTrue(self.code("return page.evaluate(()=>document.activeElement.textContent==='Columns');"))
self.open(english=True)
self.search("zzzz")
self.assertEqual(self.code("return page.getByRole('status').last().innerText();"), "No matching columns. Change or clear the search.")
self.capture("english-empty-320")
self.close()
def test_06_simple_consumers_and_nested_picker(self):
self.resize(1024, 768)
for section in ["resources", "people"]:
self.navigate(section)
self.open()
result = self.geometry()
self.record(section, result)
self.assertGreaterEqual(result["columns"], 3)
self.assertEqual(result["groups"], [])
self.assertTrue(result["focusSearch"])
self.code("await page.getByRole('button',{name:'Restaurar columnas',exact:true}).click();return true;")
self.capture(section + "-columns")
self.close()
self.navigate()
module = "/@fs/" + quote(str(ROOT / "packages/rdx-ui/src/picker-browser-fixture.tsx"), safe="/")
self.code("await page.evaluate(async module=>{const target=document.createElement('div');document.body.append(target);window.columnTestRoot=(await import(module)).mountPickerFixture(target,{count:12,locale:'es',longNames:false,kind:'fields'})}," + json.dumps(module) + ");await page.getByRole('button',{name:'Seleccionar campos',exact:true}).click();return true;")
self.resize(320, 640)
self.open()
self.assertEqual(self.geometry()["columns"], 3)
self.assertEqual(self.code("return page.getByRole('dialog').last().getByRole('checkbox',{name:'Campo',exact:true}).isDisabled();"), True)
self.capture("nested-picker-columns-320")
self.close()
self.assertTrue(self.code("return page.getByRole('dialog').getByRole('button',{name:'Columnas',exact:true}).evaluate(e=>e===document.activeElement);"))
self.close()
def test_07_shared_fields_and_scroll_focus(self):
self.resize(320, 640)
self.navigate()
shared = self.code("const r=await page.request.get(" + json.dumps(self.origin + "/api/asset-catalog/" + self.company.split('/')[2]) + ");const {items}=await r.json();const templates=items.filter(i=>i.values.kind==='template');const field=items.find(i=>i.values.kind==='field'&&templates.filter(t=>t.values.fields.some(f=>f.fieldId===i.id)).length>1);return {id:field.id,name:field.values.name,templates:templates.filter(t=>t.values.fields.some(f=>f.fieldId===field.id)).map(t=>t.values.name)};")
self.open()
self.record("shared-field", shared)
selector = 'label[for$="characteristic-' + shared["id"] + '"]'
self.search(shared["name"])
self.assertEqual(self.code("return page.getByRole('dialog').locator(" + json.dumps(selector) + ").count();"), 1)
self.capture("shared-field-one-option")
for template in shared["templates"][:2]:
self.search(template)
self.assertEqual(self.code("return page.getByRole('dialog').locator(" + json.dumps(selector) + ").count();"), 1)
self.search("")
self.code("await page.getByRole('dialog').getByRole('checkbox').last().focus();return true;")
result = self.code("return page.getByRole('dialog').evaluate(d=>{const s=d.querySelector('[data-slot=form-body]'),r=s.getBoundingClientRect(),f=document.activeElement.getBoundingClientRect();return {scroll:s.scrollTop,visible:f.top>=r.top&&f.bottom<=r.bottom,focusVisible:document.activeElement.matches(':focus-visible')}});")
self.record("last-checkbox-focus", result)
self.assertGreater(result["scroll"], 0)
self.assertTrue(result["visible"] and result["focusVisible"])
self.capture("scroll-focus-last-320")
traversal = self.code("await page.getByRole('dialog').getByRole('textbox').focus();const outside=[];for(let i=0;i<85;i++){await page.keyboard.press('Tab');await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));const focus=await page.getByRole('dialog').evaluate(d=>d.contains(document.activeElement)?null:{tag:document.activeElement.tagName,role:document.activeElement.getAttribute('role'),label:document.activeElement.getAttribute('aria-label')});if(focus)outside.push({step:i,focus})}return outside;")
self.record("keyboard-traversal", traversal)
self.assertEqual(traversal, [])
self.close()
def test_08_actual_inventory_in_english(self):
self.resize(1440, 900)
self.navigate()
self.code("await page.locator('summary').click();await page.getByRole('combobox',{name:'Idioma'}).click();await page.getByRole('option',{name:'English',exact:true}).click();await page.locator('summary').click();return true;")
try:
self.open(english=True)
self.assertEqual(self.geometry()["groups"], ["General", "Status and location", "Audit", "Infrastructure", "Catalog characteristics"])
self.assertTrue(self.code("return page.getByRole('dialog').getByText('8 visible columns of 75',{exact:true}).isVisible();"))
self.capture("assets-english-1440")
self.resize(320, 640)
self.capture("assets-english-320")
self.close()
finally:
self.code("await page.locator('summary').click();await page.getByRole('combobox',{name:'Language'}).click();await page.getByRole('option',{name:'Español',exact:true}).click();await page.locator('summary').click();return true;")
if __name__ == "__main__":
unittest.main()

View File

@ -93,6 +93,8 @@ class VisualUiTests(unittest.TestCase):
def test_selected_row_contrast(self):
self.resize(1920, 1080)
self.navigate("assets")
# Con datos voluminosos el activo de referencia puede quedar en otra página.
self.code("await page.locator('main input[data-quick-search]').fill('AUDIT-PORT-01');return true;")
self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).waitFor();return true;")
values = []
for state in ("normal", "normal-hover", "normal-focus", "selected", "hover", "focus"):
@ -103,11 +105,10 @@ class VisualUiTests(unittest.TestCase):
if state.endswith("focus"):
self.code("await page.mouse.move(0,0); await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); return true;")
self.settle()
measurements = self.code("""return await page.evaluate(()=>{
measurements = self.code("""return await page.getByRole('row').filter({has:page.getByRole('link',{name:'AUDIT-PORT-01',exact:true})}).evaluate(row=>{
const rgb=c=>{const ctx=document.createElement('canvas').getContext('2d');ctx.fillStyle=c;ctx.fillRect(0,0,1,1);return [...ctx.getImageData(0,0,1,1).data].slice(0,3)};
const background=e=>{const colors=[];for(let n=e;n;n=n.parentElement)colors.unshift(getComputedStyle(n).backgroundColor);const ctx=document.createElement('canvas').getContext('2d');ctx.fillStyle='#fff';ctx.fillRect(0,0,1,1);for(const color of colors){ctx.fillStyle=color;ctx.fillRect(0,0,1,1)}return [...ctx.getImageData(0,0,1,1).data].slice(0,3)};
const lum=c=>c.map(v=>v/255).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((s,v,i)=>s+v*[.2126,.7152,.0722][i],0);
const row=[...document.querySelectorAll('tr')].find(e=>e.textContent.includes('AUDIT-PORT-01'));
return [...row.querySelectorAll('a,button,[class*=text-muted-foreground]')].map(e=>{const fg=rgb(getComputedStyle(e).color);const bg=background(e);const l=[lum(fg),lum(bg)].sort((a,b)=>a-b);return {label:e.textContent.trim()||e.getAttribute('aria-label'),fg,bg,ratio:(l[1]+.05)/(l[0]+.05)};});
});
""")
@ -531,7 +532,7 @@ class VisualUiTests(unittest.TestCase):
def inventory_metrics(self):
return self.evaluate("""(()=>{const root=document.querySelector('[data-slot=inventory-layout]');const panel=root.querySelector(':scope>aside');const table=root.querySelector('table');const viewport=table.closest('[data-slot=table-container]');return {mode:root.dataset.detailMode,open:root.dataset.detailOpen,selected:root.dataset.selectedAsset??root.dataset.selectedResource,width:root.getBoundingClientRect().width,table:viewport.clientWidth,panel:panel?.getBoundingClientRect().width??0,gap:parseFloat(getComputedStyle(root).columnGap),dialogs:document.querySelectorAll('[role=dialog]').length,focus:document.activeElement.getAttribute('aria-label')||document.activeElement.textContent.trim().slice(0,60),bodyFocus:document.activeElement===document.body,inert:!!root.closest('[inert]'),sameTable:!window.inventoryTable||table===window.inventoryTable,url:location.href}})()""")
return self.code("""return await page.locator('[data-slot=inventory-layout]:has(table)').evaluate(root=>{const panel=root.querySelector(':scope>aside');const table=root.querySelector('table');const viewport=table.closest('[data-slot=table-container]');return {mode:root.dataset.detailMode,open:root.dataset.detailOpen,selected:root.dataset.selectedAsset??root.dataset.selectedResource,width:root.getBoundingClientRect().width,table:viewport.clientWidth,panel:panel?.getBoundingClientRect().width??0,gap:parseFloat(getComputedStyle(root).columnGap),dialogs:document.querySelectorAll('[role=dialog]').length,focus:document.activeElement.getAttribute('aria-label')||document.activeElement.textContent.trim().slice(0,60),bodyFocus:document.activeElement===document.body,inert:!!root.closest('[inert]'),sameTable:!window.inventoryTable||table===window.inventoryTable,url:location.href}});""")
def inventory_select(self):
self.code("await page.getByRole('row').filter({has:page.getByRole('link',{name:'AUDIT-PORT-01',exact:true})}).locator('td').nth(2).click();return true;")
@ -544,6 +545,7 @@ class VisualUiTests(unittest.TestCase):
def test_inventory_container_capacity(self):
self.resize(1920,1080)
self.navigate('assets')
self.code("await page.locator('main input[data-quick-search]').fill('AUDIT-PORT-01');return true;")
self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).waitFor();await page.evaluate(()=>window.inventoryTable=document.querySelector('main table'));return true;")
for width, mode in [(1143,'overlay'),(1144,'inline'),(1145,'inline'),(1224,'inline'),(1200,'inline'),(1143,'overlay')]:
self.inventory_width(width)

View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Amplía únicamente la instalación desechable de visual-audit.py mediante HTTP real.
Requiere METIS_PLAYWRIGHT_STORAGE_STATE. Repetir reutiliza registros por nombre.
No lee .env ni almacena credenciales; la sesión privada sigue perteneciendo al preparador.
"""
import json
import os
from pathlib import Path
import runpy
import tomllib
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parent.parent
AUDIT = runpy.run_path(str(ROOT / "scripts/visual-audit.py"))
def main():
value = os.environ.get("METIS_PLAYWRIGHT_STORAGE_STATE")
if not value:
raise RuntimeError("Requiere sesión del preparador desechable.")
state = (ROOT / value).resolve()
if not state.is_relative_to(ROOT / ".artifacts/playwright") or state.stat().st_mode & 0o077:
raise RuntimeError("La sesión debe ser privada y estar en el directorio de auditoría.")
report = json.loads((state.parent / "report.json").read_text())
if urlparse(report["origin"]).hostname != "localhost" or report.get("ordinaryUser") is not True:
raise RuntimeError("Requiere instalación sintética local con usuario ordinario.")
config = tomllib.loads((ROOT / ".codex/config.toml").read_text())["mcp_servers"]["playwright"]
with AUDIT["browser"](config, state) as client:
code = """async (page) => {
const origin=__AUDIT_ORIGIN__, company=__AUDIT_COMPANY__;
const session=await page.request.get(origin+'/api/auth/session');
if(session.status()!==200)throw Error('Sesión no disponible');
const {csrfToken}=await session.json();
async function get(path){const r=await page.request.get(origin+'/api'+path);if(r.status()!==200)throw Error('GET '+path+': '+r.status());return r.json()}
async function post(path,data){const r=await page.request.post(origin+'/api'+path,{data,headers:{origin,'x-csrf-token':csrfToken}});if(r.status()!==201)throw Error('POST '+path+': '+r.status());return r.json()}
const companies=(await get('/companies')).companies;
if(!companies.some(c=>c.id===company&&c.name==='Auditoría visual — Datos sintéticos'))throw Error('La compañía no es la fixture del preparador');
for(let i=1;i<=14;i++){const name='AUDIT-COMPANY-'+String(i).padStart(2,'0');if(!companies.some(c=>c.name===name))await post('/companies',{name,active:i%5!==0})}
const details=await get('/companies/'+company);
const people=details.people??details.company?.people;
if(!Array.isArray(people))throw Error('No se reconoce la colección de personas');
const catalog=(await get('/asset-catalog/'+company)).items;
const laptop=catalog.find(c=>c.values.kind==='template'&&c.values.name==='Portátil');
const printer=catalog.find(c=>c.values.kind==='template'&&c.values.name==='Impresora');
if(!laptop||!printer)throw Error('Faltan plantillas de activos');
const assets=(await get('/assets/'+company)).assets;
const resourceCatalog=(await get('/resource-catalog/'+company)).items;
const application=resourceCatalog.find(c=>c.builtinKey==='template:application');
if(!application)throw Error('Falta plantilla de aplicación');
const resources=(await get('/resources/'+company)).resources;
for(let i=1;i<=50;i++){
const suffix=String(i).padStart(2,'0');
const person='AUDIT-PERSON-'+suffix+(i%10===0?' — Álvaro Muñoz de la Peña':'');
if(!people.some(p=>p.name===person))await post('/companies/'+company+'/people',{name:person,position:i%3?'Soporte':null,email:'person'+suffix+'@audit.example.test',phone:null,active:i%7!==0,isContact:i%4===0});
const name='AUDIT-ASSET-'+suffix+(i%10===0?' — Equipo de documentación e ingeniería':'');
if(!assets.some(a=>a.values.name===name))await post('/assets/'+company,{values:{name,templateId:i%5===0?printer.id:laptop.id,siteId:null,registeredOn:i%3?'2026-01-15':null,status:i%7===0?'retired':'operational',notes:i%10===0?'Notas sintéticas de tamaño representativo. '.repeat(15):'',characteristics:{}}});
const resource='AUDIT-VOLUME-APP-'+suffix;
if(!resources.some(r=>r.values.name===resource))await post('/resources/'+company,{values:{name:resource,templateId:application.id,active:i%7!==0,notes:i%10===0?'Aplicación sintética para pruebas con grandes volúmenes. '.repeat(15):'',characteristics:{}}});
}
const inventory=await get('/assets/'+company);
const finalDetails=await get('/companies/'+company);
return {companies:(await get('/companies')).companies.length,people:(finalDetails.people??finalDetails.company.people).length,assets:inventory.assets.length,resources:(await get('/resources/'+company)).resources.length,companyId:company};
}""".replace("__AUDIT_ORIGIN__", json.dumps(report["origin"])).replace("__AUDIT_COMPANY__", json.dumps(report["companyId"]))
result = AUDIT["result_json"](AUDIT["tool"](client, "browser_run_code_unsafe", {"code": code}))
(state.parent / "volume.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()