feat: Introduce contextual detail layout and budget management for assets and resources

- Added `INVENTORY_DETAIL_BUDGET` and `RESOURCE_DETAIL_BUDGET` to define layout parameters for inventory and resource detail views.
- Implemented `useContextualDetail` hook to manage visibility and focus transitions between inline and overlay modes.
- Created `contextual-detail-layout` module to handle budget calculations and state management for contextual details.
- Updated documentation to reflect changes in asset and resource handling, ensuring consistent UI behavior across different viewports.
- Conducted regression tests to validate functionality and ensure no existing features were broken during the implementation.
This commit is contained in:
David Arranz 2026-10-22 01:24:37 +02:00
parent 74b63cb385
commit f9384c9376
33 changed files with 1393 additions and 90 deletions

View File

@ -139,8 +139,8 @@ Implementación representativa: [CompaniesWorkspace](modules/companies/src/web/w
| Cabecera 88 px; búsqueda 41 px; navegación 48 px; avatar 35 px | Misma familia de tokens |
| Navegación de compañía 240 px en shell; contenido flexible | Grid de CompanyDetail |
| `shell: 768px`, `inventory: 1100px`, `dashboard: 1200px` | `--breakpoint-*`, global.css app |
| Panel de inventario `clamp(320px,24vw,400px)` con gap-6 | [AssetInventoryPage](modules/assets/src/web/assets/list/ui/inventory-page.tsx) |
| Detalle compacto hasta 1099 px | [useCompactDetail](packages/rdx-ui/src/hooks/use-compact-detail.ts) |
| Activos: tabla 800 px, separación 24 px, panel 320–400 px; inline con W ≥ 1144 px del contenedor | [V01.3](docs/inventario-responsive-v01-3.md) |
| Recursos: tabla 640 px, separación 24 px, panel 320–400 px; inline con W ≥ 984 px del contenedor | [V01.4](docs/recursos-responsive-v01-4.md) |
Estos son umbrales existentes, **no mínimos operativos validados para layouts futuros**. V01.1 implementa la composición móvil por debajo de `shell` (768 px): cabecera compacta, franja de compañía/sección y navegación global/contextual mediante un único Sheet modal abierto a la vez. La búsqueda móvil reutiliza la lógica existente y el salto al contenido conserva foco accesible. En los datos sintéticos verificados, `main` comienza en Y=106 px a 320 y 390 px; la ampliación de texto puede aumentar ese alto. V01.2 extiende el Sheet y la franja a los tamaños sin capacidad para columna fija. Véanse [V01.1](docs/navegacion-movil-v01-1.md) y [navegación responsive V01.2](docs/navegacion-responsive-v01-2.md).
@ -148,7 +148,7 @@ La política de V01.2 mide el contenedor del shell y distingue tres presupuestos
Cabecera sticky y columna contextual con scroll propio conservan una única zona principal de scroll. El foco de enlaces/activadores que desaparecen se traslada a un equivalente visible; los menús que pasan a fijos liberan modalidad. Se conserva el foco y estado del contenido que permanece montado.
V01 sigue parcialmente pendiente: V01.3/V01.4 deben resolver tabla/panel por capacidad y migración/regresión final. `inventory`, `dashboard` y `useCompactDetail` mantienen sus contratos; abrir un panel no cambia los modos del shell.
V01.3/V01.4 coordinan el detalle de ambos inventarios con `useContextualDetail` de rdx-ui y un ResizeObserver por caja exterior estable. Cada módulo define su presupuesto; no se utiliza el viewport ni la columna que encoge. Abrir/cerrar detalle no altera el shell. Perder capacidad conserva selección, cierra panel y ofrece reapertura explícita; ganar capacidad conserva el contexto abierto y cerrado permanece cerrado. Recursos conserva selección en URL, cierre compacto y diálogo explícito propios. El breakpoint `inventory` y `useCompactDetail` permanecen como API anterior, sin gobernar estos inventarios; `dashboard` no cambia. El cierre formal se documenta en [V01.4](docs/recursos-responsive-v01-4.md).
Usar [BrowserLink y rutas existentes](docs/rutas-del-cliente.md) para destinos reales; botones para acciones, selección local y modales. Conservar acceso directo, recarga, Atrás/Adelante, clic modificado y sección activa accesible. No inventar rutas de edición o fichas para entidades que no las tienen. Si el esquema narrativo de rutas queda desactualizado, contrastarlo con `workspace/shared/helpers/workspace-routes.ts`; no cambiar el modelo de negocio para adaptar una URL.
@ -238,8 +238,8 @@ No declarar cumplimiento WCAG global a partir de tokens, snapshots, capturas o u
Vigente: distribución por contenedor para grupos de formulario, orden DOM conservado, acciones accesibles, nombres que puedan partirse y tabla desplazable dentro de su área. Los umbrales concretos de componentes como `TemplateMetadataSection` están en el patrón de diálogos; no son breakpoints globales nuevos.
- Escritorio amplio: mostrar datos comparables y paneles cuando quepan; evitar márgenes ornamentales y tarjetas sin función.
- Portátil/tablet: aplicar la política del shell V01.2 y evaluar después el ancho restante para el panel. Los 1100 px de inventario siguen sin garantizar capacidad operativa, pendiente V01.3.
- Móvil: contenido y acciones alcanzables sin desbordamiento global horizontal; la tabla puede tener scroll propio. V01.1 mantiene compañía y sección en una franja sticky, y sus destinos en Sheet; la navegación de portátil/tablet sigue V01.2 y la adaptación del panel sigue pendiente V01.3.
- Portátil/tablet: aplicar la política del shell V01.2 y después la capacidad real del inventario de activos según V01.3. No deducir el modo contextual del viewport; recursos aplica el presupuesto propio de V01.4.
- Móvil: contenido y acciones alcanzables sin desbordamiento global horizontal; la tabla puede tener scroll propio. V01.1 mantiene compañía y sección en una franja sticky, y sus destinos en Sheet; la navegación de portátil/tablet sigue V01.2 y el detalle de activos sigue V01.3.
- Ventanas bajas: altura dinámica, cuerpo desplazable y pie visible; no reducir campos para hacerlos caber. El presupuesto de ayuda y la versión compacta de progreso siguen pendientes, V04.
- Formularios y tablas extensas: usar grupos/pestañas/componentes existentes; truncar solo con acceso al valor completo cuando sea necesario. La búsqueda del selector de columnas, V08, y el resumen de ficha, V10, requieren implementación o diseño adicional.
@ -259,7 +259,7 @@ V02, V03, V06 y V09 están **resueltos en los escenarios verificados del sprint
| ID | Tipo y alcance | Próxima decisión o comprobación |
| --- | --- | --- |
| V01 | Implementación parcial: V01.1 móvil + V01.2 navegación por capacidad | Pendientes V01.3 tabla/panel y V01.4 migración/regresión final; no está cerrado |
| V01 | Resuelto en los escenarios verificados de V01.1–V01.4: navegación y ambos inventarios por capacidad | 28 métodos visuales y once resoluciones; presupuestos/contratos documentados en [V01.4](docs/recursos-responsive-v01-4.md). Fallos globales previos identificados |
| V02 | Resuelto: combinación de tokens/estados del inventario claro | Contraste y foco comprobados; no equivale a validar todos los temas o tablas |
| V03 | Resuelto: adopción de patrón y pie estable | Tres pasos de activos/recursos verificados en los cuatro tamaños del sprint |
| V04 | Diseño de ayuda/progreso compacto + componentes | Acordar presupuesto de altura sin perder ayuda junto al pie; verificar 320×640 |

View File

@ -478,3 +478,18 @@ El 21 de octubre de 2026 se implementa la [segunda entrega V01.2](navegacion-res
La nueva matriz sintética de cinco pantallas y once resoluciones conserva las mediciones originales de esta auditoría como historia. En Activos, main pasa de 972 a 1128 px en 1440, de 556 a 952 en 1024 y de 300 a 768 en 768. El móvil conserva Y=106; no se registra desbordamiento horizontal del documento en las 55 mediciones posteriores.
Se mantienen los 21 destinos y sus contadores; se verifican preferencia manual frente a colapso automático, intención contextual temporal, scroll independiente, tooltips, enlaces, teclado, foco y resize. La regresión detectada en activadores que desaparecen tenía una prueba que dejaba el foco en BODY; su corrección traslada el foco a un activador/enlace visible. Resultados de regresiones V02/V03/V06/V09, comandos, capturas y límites se registran en el informe de entrega, sin convertir los checks globales fallidos en pruebas aprobadas.
## Seguimiento V01.3 — Detalle de activos por capacidad
La [tercera entrega V01.3](inventario-responsive-v01-3.md) sustituye exclusivamente en activos el umbral de viewport por la caja exterior estable del inventario. Las mediciones originales de esta auditoría y V01.2 se conservan. La tabla útil con selección pasa de 453 a 797 px a 1160 de viewport y de 634 a 978 px a 1100. A 1920 permanece el panel de 400 px y la tabla útil de 977 px. El umbral real W=1144 deja 800 px de columna de tabla (798 interiores tras bordes).
El informe de entrega documenta límites exactos, identidad de tabla, estado, foco, transiciones y regresión con Playwright MCP autenticado y datos sintéticos. Una prueba detectó foco en una guarda de Base UI al transferir diálogo a panel; la implementación incorpora la transferencia tras liberar la modalidad y vuelve a comprobarla. Los errores globales se registran como fallos, no como validaciones superadas. V01 sigue **parcialmente resuelto**: recursos conserva su presentación anterior hasta V01.4. No cambia el estado de los demás hallazgos.
## Seguimiento V01.4 — Cierre de V01
El 22 de octubre de 2026 se completa [V01.4](recursos-responsive-v01-4.md), conservando esta auditoría como línea base. Recursos adopta un mínimo de contenedor de 984 px (tabla 640 + separación 24 + panel 320), con su selección en URL y diálogo explícito existentes. Activos mantiene 1144 px y sus geometrías de V01.3. El mecanismo independiente del dominio se comparte en rdx-ui, sin modificar shell, vendor, permisos ni rutas.
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.

View File

@ -556,18 +556,50 @@ El selector se aplica a activos del puesto (adición múltiple), ordenador princ
## Panel lateral y resumen de recursos
Recursos aplica el mismo patrón de selección que activos: el listado comienza sin ficha,
el nombre y las zonas sin controles de cada fila abren el recurso y conservan la pestaña
y la búsqueda de la URL. La selección se resalta mientras el panel está abierto. El cierre
oculta el panel y ofrece recuperarlo desde la cabecera. Las URLs de un recurso concreto
siguen abriendo su ficha; en pantallas pequeñas se conserva el diálogo con historial.
Recursos comienza sin detalle salvo selección inicial. El nombre y las zonas sin controles
seleccionan la ruta del recurso, conservando pestaña y búsqueda; el nombre de un activo,
en cambio, navega a su página completa. La selección se resalta mientras el detalle está
abierto. Cerrar el panel inline lo oculta y ofrece reapertura desde la cabecera; cerrar
el diálogo contextual en ruta conserva closeRoute. «Ficha e historial» abre el diálogo
explícito existente, también en escritorio, sin convertirlo en una página nueva.
`SelectionPanel` de `@repo/rdx-ui/components/selection-panel` comparte la estructura de
activos y recursos: título, cierre accesible, resumen, acciones y contenido por composición.
El consumidor aporta textos traducidos, datos y callbacks. `useCompactDetail` comparte el
umbral de 1100 px con la variante `inventory` de Tailwind. Las fichas completas conservan
su diálogo; el panel de recursos muestra características, integración, notas y contenido
relacionado, sin repetir el historial completo.
El consumidor aporta textos traducidos, datos y callbacks. `layout="responsive"` es el
valor predeterminado y conserva el umbral anterior de viewport `inventory` para
consumidores que no decidan su presentación por capacidad. `layout="inline"` deja la decisión al consumidor, sin otro umbral CSS.
Activos y recursos utilizan esta segunda opción mediante el coordinador compartido
de [V01.4](recursos-responsive-v01-4.md), con presupuestos propios. El panel de recursos muestra características,
integración, notas y contenido relacionado, sin repetir el historial completo.
### Capacidad contextual de los inventarios
`useContextualDetail({ budget, initialVisible })`, de `@repo/rdx-ui/hooks/use-contextual-detail`,
se extrae de V01.3 con dos consumidores reales. Observa `containerRef`, publica modo
inline/overlay discreto y visibilidad, y desconecta al desmontar. No recibe DTO, identidad,
compañía, ruta, consulta ni permisos. `contextual-detail-layout` comparte la política y el
grid; cada listado mantiene un presupuesto estable: activos 800/24/320–400 (mínimo 1144),
recursos 640/24/320–400 (mínimo 984). El contenedor publica `data-detail-mode` y
`data-detail-open`; `SelectionPanel` publica `data-slot="selection-panel"` y su cabecera
con cierre accesible. No medir la columna de tabla que cambia al abrir detalle.
El hook aporta `reopenRef`, `setVisible`, `captureTrigger`, `finalFocus`, `triggerFinalFocus`
y `restoreFocus`. Los diálogos delegan modalidad en Base UI; los dos últimos contratos
permiten restituir un disparador válido de editor o coordinar la reposición de un panel
tras un diálogo explícito. Los frames se cancelan al desmontar. `setVisible(false)` en
overlay restaura foco después del commit, cuando reaparece el control. No añadir traps
ni listeners globales para replicar Base UI. Si una actualización externa retira
un diálogo sin pasar por onClose, el consumidor coordina también la restitución del
foco tras el commit; en recursos esta responsabilidad permanece en la página, sin
trasladar consultas o identidad al hook.
`AssetList`/`EntityTable` y la tabla de recursos permanecen montados en su misma posición.
La selección sigue en el módulo. Perder capacidad no abre un modal; ofrece «Mostrar
detalle seleccionado». El contexto abierto pasa de modal a panel al ganar capacidad;
si estaba cerrado, sigue cerrado. Recursos conserva su diálogo explícito de ficha e
historial y retira el panel mientras se consulta; cerrar recupera el contexto previo
que continúe disponible. El cierre compacto en ruta conserva closeRoute. No crear una
página de recursos ni alterar su navegación para imitar activos.
`characteristicsSummary` de `@repo/rdx-ui/lib/characteristics-summary` recibe campos,
valores y textos de Sí/No sin depender de DTOs. Activos y recursos muestran los primeros

View File

@ -0,0 +1,144 @@
# Inventario de activos por capacidad — V01.3
## Arquitectura y alcance
Entrega sobre V01.1/V01.2, sin cambiar su shell. La selección sigue en `useAssetInventoryPageController`; `useInventoryDetail` coordina capacidad y visibilidad contextual. No se modifica la navegación a fichas completas, permisos, API, datos, vendor ni dependencias. Recursos queda expresamente fuera: su migración corresponde a V01.4 y V01 continúa parcialmente resuelto.
Una referencia al `div[data-slot=inventory-layout]` observa su content box mediante un único `ResizeObserver`. El observer se desconecta al desmontar, publica solamente cruces de modo y comienza en `overlay` hasta recibir una medida fiable. No observa la columna que encoge, no usa `innerWidth` ni guarda píxeles como estado React. El grid no cambia de anchura exterior al mostrar el panel: no hay realimentación. El reducer devuelve el mismo estado ante capacidad/visibilidad iguales.
`inventory-detail-layout.ts` es la fuente única del presupuesto, la decisión y las columnas. No se replica un umbral en CSS. `SelectionPanel` añade una opción explícita `layout="inline"`; su valor predeterminado `responsive` conserva exactamente el contrato CSS de recursos. `useCompactDetail` y el breakpoint `inventory` siguen disponibles para ese consumidor.
## Presupuesto y presentación
| Concepto | Valor efectivo |
| --- | --- |
| Columna de tabla de referencia | 800 px |
| Separación | 24 px |
| Panel | 320–400 px |
| Umbral content box exterior W | 1144 px inclusive |
| Panel cuando cabe | clamp(320 px, W − 824 px, 400 px) |
| Tabla interior en el límite | 798 px útiles; 2 px de borde exterior de EntityTable |
No se incrementa artificialmente el umbral por las columnas opcionales. Su desbordamiento horizontal permanece dentro de la tabla. Con W inferior al mínimo, el listado ocupa toda la columna y la selección explícita abre `AssetDetailDialog`, con `FormDialog`, título y detalle compacto existentes. No queda un panel oculto focusable ni una columna vacía.
El panel inline es un `aside` cuyo nombre accesible incluye el nombre del activo, con cierre y acciones existentes. No atrapa foco ni bloquea la tabla. Se conservan todos los campos, acordeones, relaciones y permisos del panel existente. El diálogo conserva su contenido e historial; esta entrega no homogeneiza ambos contenidos.
## Selección, visibilidad y transiciones
| Evento | Selección | Presentación y foco |
| --- | --- | --- |
| Pulsar zona no interactiva de fila | Activo pulsado | Abre en el modo disponible |
| Enlace del nombre o Ficha e historial | Contrato de ruta existente | Ficha completa, sin nueva ruta |
| Cerrar detalle | Se conserva | Sin panel ni modal; botón «Mostrar detalle seleccionado» |
| Inline → overlay | Se conserva | Cierra detalle; nunca abre modal; foco del panel al botón de reapertura, foco de tabla permanece |
| Overlay abierto → inline | Se conserva | Desmonta modal, libera modalidad y muestra panel; foco al cierre del panel |
| Overlay cerrado → inline | Se conserva | Permanece cerrado |
| Cambios dentro del mismo modo | Se conserva | Sin cambio de visibilidad ni reconstrucción de tabla |
| Desaparición externa del activo | Contrato anterior | Aviso existente `EntityNotFound`, sin inventar una selección alternativa |
| Borrado confirmado | Contrato anterior | Limpia selección y detalle solo tras éxito de la mutación |
No se ejecutan borrados en la auditoría. La desaparición externa se simula en la respuesta HTTP de la instalación sintética. Un filtro o cambio de página no elimina la selección si el activo sigue en el inventario cargado. Ante desaparición externa se conserva el nodo exterior observado y el aviso previo; si desaparece el elemento enfocado, el contenedor recibe foco para evitar BODY.
Base UI conserva Escape, modalidad y retorno ordinario mediante `finalFocus`. El destino alternativo local comprueba disparador conectado/habilitado, control de reapertura, búsqueda y contenedor. En el cambio de modal a panel se enfoca el cierre en el siguiente frame, una vez retiradas las guardas/modalidad; el frame se cancela al desmontar o cambiar de estado. No se añade un focus trap ni listeners globales. La edición conserva el contrato V06.
## Estabilidad y geometrías
`AssetList` y su única `EntityTable` permanecen en la misma posición de React y DOM; no hay claves o ramas de layout que las reconstruyan. Cambiar capacidad no cambia URL, consultas, búsqueda, plantilla, filtros, orden, página ni preferencias de columnas. El scroll vertical se conserva cuando sigue existiendo contenido suficiente; no se promete conservar un desplazamiento superior al nuevo máximo físico tras retirar un panel largo.
Mediciones reales con usuario ordinario, compañía/portátil sintéticos, global predeterminada y compañía fija cuando cabe. Anchos en px; tabla = viewport horizontal interior. Las medidas previas permanecen en `before/measurements.json`, las nuevas en `after/measurements.json`.
| Viewport | W inventario | Tabla con detalle antes | Panel antes | Tabla después | Presentación después |
| --- | ---: | ---: | ---: | ---: | --- |
| 1920×1080 | 1403 | 977 | 400 | 977 | Inline, panel 400 |
| 1660×900 | 1143 | 719 | 398,39 | 1141 | Overlay |
| 1440×900 | 1079 | 707 | 345,59 | 1077 | Overlay |
| 1366×768 | 1005 | 651 | 327,83 | 1003 | Overlay |
| 1160×800 | 799 | 453 | 320 | 797 | Overlay |
| 1100×800 | 980 | 634 | 320 | 978 | Overlay |
| 1024×768 | 904 | 902 | Oculto | 902 | Overlay |
| 920×768 | 800 | 798 | Oculto | 798 | Overlay |
| 768×1024 | 720 | 718 | Oculto | 718 | Overlay |
| 390×844 | 358 | 356 | Oculto | 356 | Overlay |
| 320×640 | 288 | 286 | Oculto | 286 | Overlay |
En 1660 se evita específicamente confundir viewport amplio con capacidad suficiente: W=1143 queda 1 px por debajo. Compactar manualmente global aumenta W y habilita inline; expandirla lo cierra sin abrir modal. Ocultar/fijar compañía también recalcula la medida. El panel no controla las políticas del shell.
## Pruebas, evidencias y límites
Las evidencias locales se guardan en `.artifacts/playwright/v01-3/`, ignorado por Git. Se utiliza una sola instalación de `scripts/visual-audit.py`, preparada por los endpoints reales, con login de usuario ordinario y 403 verificado en gestión de usuarios. Cada cliente de auditoría se conecta al servidor MCP Docker fijado en `.codex/config.toml`; Chromium headless no se instala en WSL. Se bloquean escrituras HTTP en las pruebas visuales.
- `before/` y `after/`: inventario cerrado y seleccionado en las once resoluciones, 22 estados por fase, geometrías y consola. Se abrieron e inspeccionaron capturas comparativas.
- Prueba de capacidad: W=1143, 1144, 1145, 1200 y 1224, en el mismo viewport; se mide la caja real y se comprueban mínimos, ausencia de doble detalle y nodo de tabla.
- Pruebas de foco: contracción con foco en panel y en acción de fila, ampliación con modal abierto, reapertura por teclado, Escape, edición/cancelación y selector anidado.
- Prueba de estado: fixture HTTP de 41 activos derivada del portátil sintético, sin insertarlos en BD. Se comprueban búsqueda, plantilla, orden descendente, segunda página, columnas, scroll horizontal/vertical y nodo DOM al cruzar cuatro veces el límite.
- Prueba de desaparición: fixture HTTP vacía y refetch por foco; no borra datos. El enlace nativo sigue abriendo la ficha completa.
- Regresión: suite existente de navegación V01.1/V01.2, selección V02, asistentes V03, retorno V06 y cabeceras V09. El caso de contraste se ejecuta ahora a 1920 porque el panel inline ya no corresponde a 1440; conserva los mismos estados y colores.
Capturas representativas abiertas e inspeccionadas: [1100 antes](../.artifacts/playwright/v01-3/before/selected-1100.png), [1100 con diálogo](../.artifacts/playwright/v01-3/after/selected-1100.png), [1920 inline](../.artifacts/playwright/v01-3/after/selected-1920.png), [320 con diálogo](../.artifacts/playwright/v01-3/after/selected-320.png), [límite W=1144](../.artifacts/playwright/v01-3/targeted/inventory-boundary-1144.png), [reapertura explícita tras resize rápido](../.artifacts/playwright/v01-3/rapid/rapid-transition-final.png). `manifest.json` registra dimensiones, tamaño y SHA-256 de los PNG, sin información de sesión.
### Resultados ejecutados
| Comprobación ejecutada | Resultado |
| --- | --- |
| Playwright MCP, cobertura completa reejecutada en tres grupos | 21/21: inventario/foco 6/6, navegación 8/8, componentes/formularios 7/7; sin casos duplicados ni omitidos |
| Resize durante apertura/animación | Tres ciclos rápidos correctos, sin modal residual ni foco en BODY |
| Resize con edición y selector abiertos | Borrador conservado, editor permanece, cancelación con foco alternativo V06; el desplegable de Base UI se cierra al recalcular geometría |
| Consola de los grupos finales y escenarios adicionales | Cero errores y warnings registrados por MCP |
| Typecheck dirigido assets, rdx-ui y web | Correcto |
| `pnpm --filter @metis/assets test` | 27 ficheros correctos |
| Inventario ejecutado directamente con Node/tsx | 24 pruebas correctas, incluidas capacidad y transiciones |
| Tests rdx-ui y resources | 11 ficheros correctos en cada paquete; contrato predeterminado de recursos conservado |
| Biome dirigido a los archivos afectados | Correcto, sin supresiones |
| `pnpm typecheck` | Falla en vendor `calendar.tsx:165`, incompatibilidad locale/exactOptionalPropertyTypes ya documentada |
| `pnpm lint` | Falla: 15 errores, 1 warning, 4 infos en archivos preexistentes ajenos al diff |
| `pnpm check` | Falla: 168 errores, 1 warning, 4 infos preexistentes; no se reformatean archivos ajenos |
| `pnpm test` con MariaDB temporal independiente | Falla: dos expectativas SSR antiguas de compañías («Personas (1)» y aviso de persona sin acceso); 7 tareas correctas de 8 ejecutadas; no se completa la suite global |
| `pnpm build` | Correcto; aviso existente de chunks mayores de 500 kB |
| Enlaces locales de los seis documentos afectados | 190 comprobados, ninguno ausente |
Los fallos globales se contrastaron con los ya documentados en V01.2 y con los archivos sin modificar. Sus logs quedan en `technical/`. La base de pruebas global se retiró al terminar cada ejecución. Las pasadas intermedias de UI que detectaron foco incorrecto o selectores/eventos inadecuados se conservan como diagnóstico, no se cuentan como aprobadas. La pasada secuencial de 21 casos terminó 20/21 al buscar el panel inicial: el guion no restauraba las anchuras de sus fixtures. Se restituyeron y se espera la medida inicial mediante frames; se añadió una comprobación explícita del modo antes del caso de foco. Se reejecutaron los 21 métodos del runner en tres grupos, incluyendo en el mismo cliente la secuencia de seis casos antes implicada. La pasada anterior no se presenta como aprobada. `verification.json` enumera los métodos y `technical/visual-*.log` conserva los resultados finales.
### Reproducción
Desde WSL con Node 24 y PNPM fijado en package.json:
```bash
python3 scripts/visual-audit.py
```
Conservar ese preparador abierto y utilizar la ruta privada que imprime, sin copiar el contenido de la sesión:
```bash
METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-XXXX/session.json \
METIS_UI_AUDIT_DIRECTORY=.artifacts/playwright/v01-3/regression \
python3 scripts/visual-audit-ui.test.py
pnpm --filter @metis/assets typecheck
pnpm --filter @repo/rdx-ui typecheck
pnpm --filter @metis/assets test
pnpm --filter @repo/rdx-ui test
```
Al finalizar esta entrega se envió SIGINT únicamente al preparador identificado por sus logs; terminó correctamente, retiró su BD y eliminó `session.json` y `database.env`. Se conservaron los contenedores ajenos y las capturas; comprobación en `cleanup.json`. Ctrl+C reproduce esa retirada de servicios y sesión del preparador. La suite global `pnpm test` necesita su propia BD de pruebas desechable, separada de la instalación visual; no usar bases existentes ni sus fixtures para preparar auditorías.
No se afirma cumplimiento WCAG completo, prueba con lector de pantalla, zoom nativo validado ni validación de otros motores/dispositivos físicos. Se conserva el estado de los otros hallazgos. Falta V01.4 para migrar recursos y realizar su regresión; el contrato explícito de SelectionPanel permite hacerlo sin modificar vendor ni duplicar política CSS/React.
## Archivos principales
- `modules/assets/src/web/assets/list/helpers/inventory-detail-layout.ts`: presupuesto, política y reducer.
- `modules/assets/src/web/assets/list/hooks/use-inventory-detail.ts`: único observer, visibilidad y coordinación de foco.
- `use-inventory-page-controller.ts` e `inventory-page.tsx`: selección, composición y reapertura; tabla estable.
- `asset-selection-panel.tsx` y `asset-detail-dialog.tsx`: semántica y finalFocus del detalle existente.
- `packages/rdx-ui/src/components/selection-panel.tsx`: variante explícita, predeterminado compatible con recursos.
- Locales es/en: etiqueta contextual independiente de presentación lateral/modal.
- `inventory.test.tsx` y `scripts/visual-audit-ui.test.py`: pruebas de política y regresión visual.
- `DESIGN.md`, patrón de listados, biblioteca de componentes, propuesta V01 y auditoría: contrato actual y seguimiento sin borrar mediciones históricas.
## Skills y APIs consultadas
Se leyeron `ux-tables` (principal: proteger comparación y manipulación), `ux-navigation` (contexto y foco), `shadcn` y referencias de composición/Base UI (modalidad y APIs existentes), `vercel-composition-patterns` (datos frente a presentación) y `vercel-react-best-practices`, incluida `rerender-derived-state` (modo discreto, sin estado por píxel). No se impone estética nueva ni se aplica la recomendación genérica de ocultar acciones frente al patrón Metis.
La CLI de documentación shadcn falló por EAI_AGAIN. Se contrastaron la API instalada Base UI 1.8.0 y las documentaciones oficiales de [Dialog shadcn/Base UI](https://ui.shadcn.com/docs/components/base/dialog) y [Dialog Base UI](https://base-ui.com/react/components/dialog), sin instalar ni regenerar componentes. No se requirieron frontend-design/ProMax para decisiones estéticas. No están instaladas las skills de auditoría `ux-heuristics-audit`, `ux-accessibility-audit`, `ux-microcopy-audit`, `ux-aesthetics-audit`, `ux-mobile-responsiveness-audit` ni `ux-information-architecture-audit` citadas por las guías. No se instalaron; se aplicaron los criterios disponibles y las verificaciones MCP.
## Seguimiento V01.4
Este informe y sus mediciones se conservan como historia. [V01.4](recursos-responsive-v01-4.md) extrae el hook a `@repo/rdx-ui/hooks/use-contextual-detail` y la política a `lib/contextual-detail-layout`, con activos y recursos como consumidores reales. El fichero de activos conserva el presupuesto 800/24/320–400; su mínimo sigue en 1144. Las 22 geometrías de activos repetidas en V01.4 coinciden con esta entrega. No se conserva una copia del observer específico. El estado vigente y el cierre de V01 se consultan en V01.4.

View File

@ -65,7 +65,7 @@ Específico: título, subtítulo, ámbito, alta y acciones globales, campos busc
## Responsive, accesibilidad y estados
Cabecera y bandas se apilan en anchuras pequeñas. Los filtros ajustan sus controles, la tabla permite desplazamiento horizontal dentro de su contenedor y las celdas largas pueden partir texto. Mantener la navegación del espacio de compañía y el panel de activos en escritorio, con la ficha modal móvil existente.
Cabecera y bandas se apilan en anchuras pequeñas. Los filtros ajustan sus controles, la tabla permite desplazamiento horizontal dentro de su contenedor y las celdas largas pueden partir texto. El inventario de activos decide panel o diálogo por el content box exterior estable, según [V01.3](inventario-responsive-v01-3.md): mínimo 1144 px, con 800 de tabla, 24 de separación y 320–400 de panel. No medir la columna que encoge ni decidir por viewport. Una pérdida de capacidad conserva selección, cierra el panel y ofrece reapertura explícita; nunca abre un modal por resize. El detalle abierto pasa de diálogo a panel al ganar capacidad, con transferencia de foco y liberación de modalidad. Mantener una sola tabla y una sola presentación interactiva del detalle. Recursos aplica [V01.4](recursos-responsive-v01-4.md): 640 px de tabla, 24 de separación y panel 320–400, con mínimo W=984. Ambos utilizan `useContextualDetail` de rdx-ui, sin duplicar observers ni políticas CSS/React. La selección, rutas y operaciones pertenecen a cada módulo: el nombre de recurso conserva selección en URL y su ficha explícita es un diálogo; no copiar la navegación a página de activos. No generalizar estos presupuestos a otros listados.
Usar tabla semántica con nombre accesible, th con scope, controles etiquetados, foco visible, acciones por teclado y página actual con aria-current. Los elementos decorativos se ocultan al lector. Los menús y modales conservan manejo de foco de Base UI. No depender del color para comunicar información.

View File

@ -297,3 +297,9 @@ Ejecutada el 21 de octubre de 2026, con Docker Engine local en WSL amd64:
- Al cerrar con SIGINT se detuvieron los servicios temporales, se retiró el contenedor desechable y se borraron los archivos de sesión y credenciales. Se comprobó que el contenedor MariaDB previo seguía disponible y que no quedaban contenedores `metis-visual-*`.
Las imágenes e informes están bajo el directorio local de ejecución y excluidos de Git. La prueba no guarda ediciones del activo ni repite una auditoría visual completa.
## Fixtures de recursos para V01.4
`prepare_resources` del preparador incorpora 16 recursos mediante HTTP real y los builtinKey del catálogo: doce aplicaciones, carpeta compartida, licencia de software, certificado inactivo y dominio Windows. Incluye notas largas, datos ausentes, cero y false. Repetir esa preparación en su instalación desechable reutiliza las fixtures por nombre, sin escrituras duplicadas. No añade una plantilla de recurso impresora: las impresoras físicas pertenecen a activos. No se fabrican publicaciones SMB, permisos ni cuentas de dominio.
El report.json privado del preparador identifica esas fixtures sin credenciales; la sesión continúa en su archivo separado 0600. La matriz y pruebas de [V01.4](recursos-responsive-v01-4.md) verifican la consulta ordinaria de los 16 recursos, sus relaciones disponibles y los dos presupuestos del coordinador compartido. `python3 scripts/visual-audit.test.py` incluye pruebas de idempotencia y límites junto con las de aislamiento del lanzador. No reutilizar estas fixtures fuera del entorno desechable ni conectar pruebas de integración a datos existentes.

View File

@ -1,8 +1,8 @@
# V01 — Propuesta de navegación y layout responsive
Fecha: 21 de octubre de 2026. **Arquitectura B híbrida aprobada para entregas incrementales. V01 continúa parcialmente pendiente.**
Fecha de la propuesta: 21 de octubre de 2026. **Arquitectura B híbrida aprobada para entregas incrementales. V01 resuelto en los escenarios verificados de V01.1–V01.4, con cierre el 22 de octubre de 2026.**
Seguimiento: [V01.1 — navegación móvil y franja contextual](navegacion-movil-v01-1.md) implementa la primera entrega, conservando escritorio y umbrales existentes. [V01.2 — navegación responsive](navegacion-responsive-v01-2.md) implementa después la política de navegación por capacidad en portátil/tablet. El resto del documento conserva alternativas, mediciones originales y arquitectura objetivo; V01.3/V01.4 siguen pendientes. Las tablas originales describen la propuesta, no sustituyen las mediciones posteriores de las entregas.
Seguimiento: [V01.1 — navegación móvil y franja contextual](navegacion-movil-v01-1.md) implementa la primera entrega, conservando escritorio y umbrales existentes. [V01.2 — navegación responsive](navegacion-responsive-v01-2.md) implementa después la política de navegación por capacidad en portátil/tablet. [V01.3 — detalle de activos](inventario-responsive-v01-3.md) y [V01.4 — recursos y cierre integral](recursos-responsive-v01-4.md) completan los inventarios y su regresión. El resto del documento conserva alternativas, mediciones originales y arquitectura objetivo. Las tablas originales describen la propuesta, no sustituyen las mediciones posteriores de las entregas.
## 1. Resumen ejecutivo
@ -476,3 +476,16 @@ No se han aplicado skills inexistentes ni recomendaciones específicas de Next.j
### Verificación de entrega
Se revisaron estructura, coherencia de wireframes/contratos, enlaces locales, evidencias y diff. La comparación con el estado inicial conserva los cambios previos del sprint sin alterarlos. Única modificación versionable de esta tarea: este documento. No se modificaron componentes, CSS, tokens, rutas, API, autenticación, vendor ni DESIGN.md. V01 y los demás hallazgos conservan su estado previo.
## Seguimiento de implementación V01.3
La propuesta anterior y sus mediciones se conservan como historia. [V01.3](inventario-responsive-v01-3.md) implementa para activos el presupuesto de §9: caja exterior W ≥ 1144 px, tabla 800, separación 24 y panel 320–400. A 1100 de viewport la tabla útil pasa de 634 a 978 px al presentar el detalle mediante diálogo. A 1660, W=1143: se utiliza overlay incluso con viewport amplio. La apertura/cierre no cambia la capacidad, y el resize nunca abre un modal. El shell V01.2 conserva sus decisiones. V01 permanece parcialmente resuelto; recursos y la regresión final de V01.4 quedan pendientes de una nueva instrucción.
## Seguimiento V01.4 — Cierre integral
El 22 de octubre de 2026, [V01.4](recursos-responsive-v01-4.md) migra recursos a capacidad propia: tabla 640 px, gap 24, panel 320–400 y mínimo W=984. Activos conserva 800/24/320–400 y mínimo W=1144. Ambos consumen useContextualDetail de rdx-ui; consultas, permisos, rutas y operaciones permanecen en sus módulos. Se conserva la selección en URL y el cierre compacto de recursos, distintos de activos.
La regresión final ejecuta 28 métodos Playwright MCP autenticados, siete de recursos y los 21 anteriores, en las once resoluciones requeridas. Todos pasan; no hay errores ni warnings de consola registrados ni overflow horizontal global en las matrices. Las 22 geometrías de activos coinciden con V01.3. Se corrigen los retornos de foco detectados, incluida la desaparición externa de un recurso, y se verifican V02/V03/V06/V09 sin rediseñarlos.
**V01 queda resuelto en los escenarios verificados de V01.1–V01.4.** Se conservan la propuesta histórica y sus límites. Los fallos globales preexistentes de typecheck/lint/check y dos expectativas SSR de compañías siguen identificados como fallos; checks dirigidos y build pasan. No cambia el estado de los hallazgos fuera de alcance. Datos, evidencias, contratos y retirada de la instancia sintética se detallan en el informe de cierre.

View File

@ -0,0 +1,197 @@
# Recursos responsive y cierre de V01 — V01.4
## Resumen y alcance
Recursos utiliza capacidad real del contenedor exterior, con un presupuesto propio de tabla de 640 px, separación de 24 px y panel de 320–400 px: umbral W ≥ 984 px. Activos conserva 800 + 24 + 320 = 1144 px. Ambos consumen la misma coordinación de capacidad y foco en `@repo/rdx-ui`, manteniendo selección, rutas y operaciones en sus módulos.
Esta entrega conserva los cambios previos de V01.3. No cambia el shell V01.1/V01.2, rutas, contratos HTTP, seguridad, datos persistentes, identidad visual, vendor ni dependencias. V04, V05, V07, V08, V10, V11 y V12 permanecen fuera de alcance.
El estado formal se determina con los resultados de la regresión al final del informe; no se deduce del build ni de una inspección de código.
## Auditoría funcional previa y contratos conservados
La revisión incluye ResourcesPage, su controlador, useEntityNavigation/closeRoute, ResourceSelectionPanel, ResourceDetailDialog, ResourceEditorDialog, DeleteResourceDialog, EntityTable, CompanyDetail y el coordinador del shell. No se presupone una ficha completa de recursos equivalente a la de activos.
| Comportamiento | Activos | Recursos | Responsabilidad |
| --- | --- | --- | --- |
| Selección por zona no interactiva | Local en listado, sin cambiar URL | Selecciona la ruta `/companies/:id/resources/:resourceId`; embebido usa selección local | Cada módulo |
| Nombre | Enlace a página completa Información/Historial | Enlace a la misma selección de recursos, conservando q/template | Cada módulo; BrowserLink conserva navegación nativa |
| Panel | Datos, acordeones y relaciones propios | Características, integración, estado y contenido relacionado aportado por compañía | Composición específica |
| Ficha e historial | Página completa | Diálogo explícito existente, también en escritorio; puede consultar otro recurso sin cambiar la selección previa | Cada módulo |
| Cierre inline | Oculta sin perder selección | Oculta sin borrar selección ni cambiar URL | Visibilidad compartida, selección propia |
| Cierre compacto voluntario | Conserva selección local | En ruta, utiliza selectEntity/closeRoute: vuelve a la ruta anterior/listado; embebido oculta detalle | Recursos conserva su semántica |
| Detalle explícito de escritorio | No aplica | Suspende el panel y lo repone al cerrar si su contexto sigue visible; no cambia URL | Recursos |
| Filtros/página | No borran selección si el elemento sigue en inventario | Igual; búsqueda por nombre y plantilla, filtro por identidad de plantilla | Módulos y EntityTable |
| Desaparición del seleccionado | Aviso EntityNotFound | Mismo aviso previo; conserva nodo exterior y ofrece foco alternativo | Módulo |
| Desaparición del consultado explícitamente | No aplica | Desaparece el diálogo y puede recuperarse el contexto seleccionado anterior | Recursos |
| Edición | Diálogo propio y retorno V06 | Diálogo propio, borrador y selectores conservados; finalFocus sobre disparador o alternativa visible | Formularios de cada módulo |
| Activación y borrado | Contratos de activos | setActive e invalidación de claves relacionadas; borrado usa operación existente y limpia selección/ruta solo tras éxito | Recursos, sin trasladarlo a rdx-ui |
| Permisos | Sesión, pertenencia y contratos existentes | Mismos controles de servidor; editar se habilita con catálogo disponible, estado pendiente bloquea acciones según contrato previo | Servidor/módulos; sin permisos nuevos |
| Medida y transiciones | Mismo mecanismo, presupuesto 800 | Mismo mecanismo, presupuesto 640 | Reutilización real en rdx-ui |
Las acciones ancladas y su menú permanecen. No se ejecutan activaciones ni borrados en la auditoría: se comprueban opciones y cancelación. La restauración al abandonar una ficha y volver al listado sigue siendo V05.
## Arquitectura compartida
`packages/rdx-ui/src/lib/contextual-detail-layout.ts` contiene el contrato `ContextualDetailBudget`, cálculo de modo/columnas y reducer de visibilidad. No importa módulos, DTO, identificadores, rutas ni consultas. Los presupuestos estables permanecen junto a cada listado: `INVENTORY_DETAIL_BUDGET` y `RESOURCE_DETAIL_BUDGET`.
`useContextualDetail({ budget, initialVisible })` sustituye el hook específico de activos. Observa un único `containerRef` y publica modo discreto, inicialmente overlay. No guarda cada píxel ni usa innerWidth. Evita publicaciones repetidas y desconecta el observer al desmontar. El hook coordina reapertura, disparador, foco final y frames cancelables; no decide qué entidad está seleccionada.
El contenedor publica `data-detail-mode` y `data-detail-open` con la presentación real. SelectionPanel publica `data-slot="selection-panel"`; su cabecera aporta el cierre equivalente. Ambos inventarios pasan `layout="inline"`, por lo que su visibilidad depende del coordinador y no del breakpoint inventory. Se conserva sin cambios la API predeterminada responsive de SelectionPanel y el hook exportado useCompactDetail; ya no deciden la presentación de activos ni recursos. No se inventan consumidores adicionales ni se elimina una API pública incidentalmente.
La tabla permanece en la misma posición, sin keys por modo ni segunda instancia. El historial, las integraciones de correo/acceso, las referencias y CompanyResourceRelatedContent permanecen en recursos/compañías. En el diálogo explícito se retira el panel, evitando una copia interactiva detrás; la tabla sigue montada.
## Presupuesto propio y evidencia
Se compararon en Chromium 600, 640 y 720 px de tabla, con nombre de carpeta de 83 caracteres, resumen técnico y las cuatro columnas iniciales, en español e inglés. A 640 px (638 interiores por los dos bordes de EntityTable), el nombre conserva unos 299–300 px, plantilla 163–164, estado unos 80 y acciones 96. No hay scroll horizontal en esa muestra. A 600, el nombre queda en unos 260–262 px; 720 no es necesario para esas cuatro columnas. Se adopta 640 para mejorar lectura sin imponer el mínimo de ocho columnas de activos.
Panel = clamp(320 px, W − 664 px, 400 px). La única fuente del presupuesto alimenta tanto el umbral como las columnas. Los 640 px son una referencia del listado actual, no una constante universal ni la suma de columnas opcionales. El scroll horizontal interno continúa permitido; las columnas de recursos se conservan localmente durante la sesión del componente, sin añadir persistencia que antes no existía.
Las pruebas del presupuesto están en `budget/measurements.json`, con capturas es/en abiertas e inspeccionadas. No se confunde ese prototipo temporal del navegador con la implementación posterior.
## Selección, transiciones y foco
- Inline → overlay: conserva selección y URL, oculta panel y muestra reapertura explícita; nunca abre un modal por resize. Si el foco estaba en el panel va a reapertura; si estaba en la tabla permanece.
- Overlay contextual abierto → inline: conserva contexto, retira modal, libera modalidad y enfoca el cierre del panel. Cerrado permanece cerrado.
- Mismo modo: no reinicializa visibilidad, tabla, orden ni selección. Se verifican variaciones sin cruzar el umbral.
- Ficha explícita: conserva su intención de diálogo aunque cambie capacidad. Al cerrarla se recupera la presentación contextual que siga disponible; no se transforma artificialmente en una página inexistente.
- FinalFocus se delega a Base UI. La coordinación posterior al commit resuelve el disparador que vuelve a ser visible o el panel que se repone. Los frames se cancelan al desmontar. No se añade focus trap ni listener global a la aplicación.
- El panel es complementario y su nombre accesible incluye el recurso. El diálogo conserva título, Escape, cierre y pie; campos y notas desplazan internamente. En edición el borrador persiste durante resize. Base UI puede cerrar un desplegable al cambiar su geometría, sin cerrar el editor.
La página coordina también la retirada del diálogo por una actualización externa: repone el panel anterior y restaura el foco tras el commit, sin alterar la consulta ni eliminar datos. Se comprueba por separado la desaparición del recurso seleccionado y su aviso existente.
El scroll se conserva cuando el nuevo contenido tiene recorrido suficiente. No se promete conservar una posición superior al máximo físico al desaparecer un panel largo. La prueba usa un desplazamiento conservable y comprueba por separado scroll horizontal en móvil.
## Entorno sintético y reproducibilidad
Se amplía exclusivamente `scripts/visual-audit.py`. `prepare_resources` usa plantillas y campos identificados por builtinKey, crea mediante HTTP normal y reutiliza fixtures por nombre si ya existen en esa instalación. No cambia usuarios ni datos reales. Nueve pruebas Python cubren aislamiento de sesión, idempotencia, catálogo incompleto, estados y conservación de cero/false.
Se preparan 16 recursos: doce aplicaciones para paginación, carpeta compartida, licencia de software, certificado inactivo y dominio Windows. Hay nombres cortos/largos, campos ausentes, fechas, número cero, booleano false y notas extensas. La carpeta y el dominio utilizan las integraciones automáticas reales. No se inventan publicaciones físicas, permisos SMB o cuentas de dominio. Las relaciones pobladas complejas no se simulan como si existieran.
No existe plantilla estándar de recurso impresora en el catálogo; las impresoras físicas son activos. No se introduce un tipo artificial para esta auditoría. Tampoco se añaden usuarios permanentes: el administrador de preparación y el ordinario solo existen en la BD desechable.
Se reutiliza la sesión ordinaria obtenida por login real. Se verifican lectura de los 16 recursos con 200, 401 sin sesión y 403 en gestión de usuarios. Las pruebas bloquean escrituras salvo la fixture ya existente de segunda compañía. No se instalan navegadores en WSL ni se altera la configuración global de Codex.
Para repetir desde WSL:
```bash
python3 scripts/visual-audit.py
```
Conservar el preparador abierto y usar la ruta privada que imprime:
```bash
METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-XXXX/session.json \
METIS_UI_AUDIT_DIRECTORY=.artifacts/playwright/v01-4/regression \
python3 scripts/visual-audit-ui.test.py
python3 scripts/visual-audit.test.py
pnpm --filter @metis/resources test
pnpm --filter @metis/assets test
pnpm --filter @repo/rdx-ui test
pnpm --filter @metis/web test
```
La suite global requiere una BD de pruebas desechable separada; no conectar sus fixtures a una instalación existente. Ctrl+C retira exclusivamente el preparador propio y su sesión privada.
## Matriz antes/después
Misma compañía sintética, preferencia global inicial y navegación de compañía fija cuando cabe. W es la caja exterior; tabla es el viewport interior. Se esperan carga, fuentes y frames. Cada fase de recursos contiene 22 estados: cerrado y seleccionado en once resoluciones.
| Viewport | W | Tabla antes | Panel antes | Tabla después | Presentación después |
| --- | ---: | ---: | ---: | ---: | --- |
| 1920×1080 | 1403 | 977 | 400 | 977 | Inline, panel 400 |
| 1660×900 | 1143 | 719 | 398,39 | 717 | Inline, panel 400 |
| 1440×900 | 1079 | 707 | 345,59 | 653 | Inline, panel 400 |
| 1366×768 | 1005 | 651 | 327,83 | 638 | Inline, panel 341 |
| 1160×800 | 799 | 453 | 320 | 797 | Overlay |
| 1100×800 | 980 | 634 | 320 | 978 | Overlay |
| 1024×768 | 904 | 902 | Oculto por CSS | 902 | Overlay |
| 920×768 | 800 | 798 | Oculto por CSS | 798 | Overlay |
| 768×1024 | 720 | 718 | Oculto por CSS | 718 | Overlay |
| 390×844 | 358 | 356 | Oculto por CSS | 356 | Overlay |
| 320×640 | 288 | 286 | Oculto por CSS | 286 | Overlay |
A 1440/1366 la tabla se ajusta a su presupuesto operativo propio mientras mejora el ancho del panel; no se afirma que todas las tablas crezcan. A 1100 y 1160 se elimina la compresión por un panel que no cabe. El panel oculto previo no permanece montado en overlay después de migrar.
También se repite la matriz de activos tras la extracción, sin modificar su mínimo de 1144. El shell sigue decidiendo global/compañía con 920/1160/1660; abrir detalle no cambia su capacidad.
## Pruebas y evidencias
Evidencias locales ignoradas por Git: `.artifacts/playwright/v01-4/`.
- `before/`, `after/`: matriz de recursos, geometrías, capturas y consola.
- `budget/`: comparación 600/640/720 con datos largos es/en.
- `assets-after/`: repetición de las once resoluciones de activos.
- Pruebas de recursos: límite 983/984/985/1064, selección por fila/nombre, URL, cierre inline/compacto, historial explícito, Atrás/Adelante, foco, edición, selector anidado, cancelación de borrado, estado/página/columnas/scroll, inglés y relaciones existentes.
- Instrumentación de observer solo en test: uno activo por inventario, ninguna publicación de modo dentro del mismo tramo y disconnect al abandonar la sección. No entra en código funcional.
- Regresión de V01.1/V01.2: franjas, cabecera, los 21 destinos, modos de shell, preferencia, ocultar/fijar, compañía, foco y scroll.
- Regresión V01.3 y V02/V03/V06/V09: presupuesto 1144, tabla/contexto de activos, selección/contraste ya corregidos, asistentes, foco y cabeceras.
### Resultados técnicos
| Comprobación ejecutada | Resultado final |
| --- | --- |
| Playwright MCP, V01.1–V01.4 y V02/V03/V06/V09 | **28/28 métodos correctos**, en cuatro grupos 12+8+7+1, repetidos tras la última corrección funcional. Ningún caso omitido. |
| Matriz de recursos | 22 estados antes + 22 después, once resoluciones; sin overflow horizontal global. |
| Matriz de activos tras extracción | 22 estados; geometrías de inventario/tabla/panel/diálogo idénticas a V01.3. |
| Consola MCP de las cuatro suites finales y las matrices | Cero errores y warnings registrados. No equivale a una auditoría de accesibilidad completa. |
| `python3 scripts/visual-audit.test.py` | **9/9** correctos. |
| Tests dirigidos de `@repo/rdx-ui`, `@metis/assets`, `@metis/resources`, `@metis/web` | **56/56, 138/138, 80/80 y 16/16**, respectivamente. |
| Contratos de navegación dirigidos | **16/16**, incluidos History, rutas y preferencia responsive. |
| Ejecución directa de contratos de selección/activos/recursos | **8/8, 24/24 y 12/12**; subconjuntos de las suites anteriores, no casos adicionales que sumar a ellas. |
| Typecheck dirigido de rdx-ui, assets, resources y web | Correcto en los cuatro paquetes. |
| Biome y ESLint de espaciado sobre los archivos afectados | Correctos. Se corrigió una línea vacía entre funciones detectada por ESLint; diagnóstico inicial conservado. |
| `pnpm typecheck` | **Falla, exit 2**: mismo TS2375 de `packages/shadcn-ui/src/components/calendar.tsx:165`, por locale opcional y exactOptionalPropertyTypes. No se toca vendor. |
| `pnpm lint` | **Falla, exit 1**: mismos 15 errores, 1 warning y 4 infos previos, sin diagnósticos en los archivos afectados. |
| `pnpm check` | **Falla, exit 1**: mismos 168 errores, 1 warning y 4 infos previos. No se reformatean archivos ajenos. |
| `pnpm test`, MariaDB temporal independiente | **Falla, exit 1**: mismas dos expectativas SSR de compañías: «Personas (1)» y «Esta persona no tiene acceso a Metis Desk». 7 tareas correctas de 8 ejecutadas; la suite global no se completa. BD retirada. |
| `pnpm build` | Correcto, exit 0; conserva el aviso de chunk mayor de 500 kB. |
La comparación con V01.3 queda en `technical/comparison.json`: mismo error TypeScript, mismos totales y conjunto de diagnósticos visibles de lint/check, ninguna incidencia en archivos cambiados y mismas expectativas fallidas de compañías. Biome dirigido comprueba íntegramente los archivos afectados, además del límite de diagnósticos de la ejecución global. No se presenta el monorepo como totalmente verde. Se comprobaron 206 destinos de enlaces locales en los documentos afectados y git diff --check, sin incidencias. El diff no incorpora archivos privados, capturas ni cambios de vendor.
`verification.json` enumera los 28 métodos únicos y su cobertura real. `screenshots.json` indexa los PNG con dimensiones y SHA-256; `README.md` organiza las evidencias. `technical/visual-*.log` conserva las pasadas finales y `technical/intermediate-*` los diagnósticos previos. Se abrieron capturas representativas de `after/selected-1366.png`, `after/selected-1100.png`, `after/selected-320.png`, presupuesto largo es/en, transferencias de foco y desaparición externa.
Las dos esperas de navegación intermedias no se reprodujeron en la pasada final. La corrección del caso adicional de desaparición externa se verificó primero de forma aislada y después dentro de los cuatro grupos finales. Las ejecuciones fallidas permanecen identificadas como tales.
## Regresiones y límites
Se detectó foco en BODY al cerrar el diálogo explícito y reponer el panel: el disparador anterior se había desmontado. Se añadió coordinación posterior al commit con alternativa visible. La regresión integral detectó además una lectura prematura de visibilidad del disparador al cerrar el detalle de activos; se coordina la restauración una vez actualizado el DOM. Una prueba adicional detectó el mismo riesgo al desaparecer externamente un recurso consultado en diálogo: al no pasar por onClose, faltaba la coordinación del foco. La página observa esa retirada real del DOM y restaura el destino tras reponer el contexto. Se verifican tanto el consultado como el seleccionado mediante transformación de respuestas HTTP, sin borrar recursos. No se introducen excepciones de autenticación ni supresiones de tipos/lint para resolverlo.
Las primeras pruebas también corrigieron supuestos del guion: el máximo de scroll se reduce físicamente al retirar el panel, y el selector de enlace por sufijo coincidía con más de un destino. Se usa desplazamiento conservable y href exacto. Estos errores de prueba no se atribuyen a reglas de negocio.
Dos esperas de navegación de una pasada intermedia agotaron su timeout, sin errores de consola registrados. Se conservan sus logs y se repiten los escenarios; no se atribuye una causa de infraestructura no demostrada ni se ocultan como aprobados.
No se afirma cumplimiento WCAG completo, validación con lector de pantalla, zoom nativo ni otros motores. La selección visual de recursos conserva sus colores previos; V02 se verifica en el alcance corregido del inventario de activos, sin extender su implementación a otros hallazgos.
## Archivos y skills
| Área | Ficheros principales y finalidad |
| --- | --- |
| Biblioteca propia | `packages/rdx-ui/src/hooks/use-contextual-detail.ts`, `lib/contextual-detail-layout.ts`: capacidad, transiciones y foco. `components/selection-panel.tsx`: marcador del panel; conserva layout predeterminado. |
| Activos, continuidad de V01.3 | `list/helpers/inventory-detail-layout.ts`, `list/controllers/use-inventory-page-controller.ts`, `list/ui/inventory-page.tsx`: consumen la extracción sin alterar el presupuesto. `detail/ui/asset-selection-panel.tsx` y `asset-detail-dialog.tsx`: presentación y finalFocus de V01.3 conservados. |
| Recursos | `list/helpers/resource-detail-budget.ts`, `list/controllers/use-resources-page-controller.ts`, `list/ui/resources-page.tsx`: presupuesto y composición específicos. `detail/ui/resource-selection-panel.tsx`, `resource-detail-dialog.tsx`, `shared/ui/resource-editor-dialog.tsx`: nombre accesible y destinos de foco. |
| Traducciones | `modules/{assets,resources}/src/common/locales/{es,en}.json`: reapertura del detalle seleccionado; activos conserva el cambio previo. |
| Contratos unitarios | `packages/rdx-ui/src/selection-components.test.tsx`, pruebas existentes `assets/shared/inventory.test.tsx` y `resources/shared/resources.test.tsx`. |
| Preparación y navegador | `scripts/visual-audit.py`, `visual-audit.test.py`, `visual-audit-ui.test.py`: recursos sintéticos y siete escenarios nuevos, junto con los 21 existentes. |
| Documentación | Este informe, DESIGN, componentes, patrón de listados, propuesta V01, auditoría, rutas, procedimiento MCP y seguimiento histórico V01.3. |
Las rutas abreviadas de módulos parten de `modules/<módulo>/src/web/<entidad>/`. El hook específico de activos se extrae a rdx-ui; no se conserva una copia paralela.
Se consultaron las skills ya leídas en la sesión: ux-tables como principal (comparación y densidad), ux-navigation (contratos de URL/foco), shadcn con referencias Base UI/composición (modalidad existente), vercel-composition-patterns (frontera módulo/biblioteca) y vercel-react-best-practices (modo discreto, efectos y cleanup). Se leyó además ux-inputs-and-forms para verificar borrador, cancelación y selectores; no se aplican recetas de campos/layout que contradigan el patrón Metis. No se requiere Pro Max para una decisión estética adicional. Los auditores especializados ausentes de la matriz no se instalan ni se presentan como ejecutados.
## Estado formal de V01
**V01 resuelto en los escenarios verificados de V01.1–V01.4, a 22 de octubre de 2026.**
| Criterio de cierre | Evidencia |
| --- | --- |
| Móvil y orientación de compañía/sección | Cabecera, franja, 21 destinos, Sheet, teclado y foco; regresión de navegación correcta. |
| Portátil/tablet/escritorio | Once resoluciones y límites de shell 920/1160/1660; preferencia e intención conservadas. |
| Ambos inventarios | Umbrales reales 1144/984; tabla operativa, un solo detalle, sin modal automático por contracción. |
| Contratos y continuidad | Selección, URL, cierre, historial, edición, estado del listado y scroll comprobados según cada entidad. |
| Sin regresiones propias pendientes | 28/28 métodos visuales, checks dirigidos y build correctos; tres escenarios de foco detectados y corregidos, incluida desaparición externa. |
| Documentación y evidencia | Matrices, logs, consola, índice PNG y seguimiento sin borrar la línea base. |
Se envió SIGINT únicamente al preparador identificado por sus logs. Terminó con exit 0, retiró su contenedor y borró session.json y database.env. Los cuatro contenedores ajenos permanecieron activos; la BD temporal de la suite global también se retiró. Comprobación en `cleanup.json`. Las capturas y metadatos sintéticos siguen disponibles localmente, excluidos de Git.
Este cierre no resuelve los fallos globales preexistentes ni extiende el alcance a V04, V05, V07, V08, V10, V11 o V12. V02/V03/V06/V09 conservan su estado en los escenarios sometidos a regresión; no se declara accesibilidad WCAG completa.

View File

@ -160,3 +160,7 @@ los enlaces de nombre y las acciones de ver abren la página. Los parámetros `q
## Presentaciones responsive de la navegación
[V01.2](navegacion-responsive-v01-2.md) cambia la presentación de los enlaces existentes según capacidad del shell: global expandida, raíl o Sheet; compañía fija o Sheet. No añade destinos ni utiliza Select para navegar. Compañía/sección/ficha siguen en la URL; apertura y ocultación contextual son temporales. Enlaces modificados mantienen nueva pestaña e historial; navegar ordinariamente desde Sheet lo cierra y enfoca contenido. El resize conserva la URL y el estado local del contenido.
### Presentación del detalle de recursos por capacidad
[V01.4](recursos-responsive-v01-4.md) conserva `/companies/:companyId/resources/:resourceId` como selección del inventario. El nombre sigue siendo un BrowserLink a esa ruta; no existe una nueva página independiente equivalente a la ficha de activos. Por capacidad se presenta panel o diálogo contextual. El cierre inline conserva URL/selección; el cierre compacto voluntario mantiene closeRoute. «Ficha e historial» conserva su diálogo explícito sin cambiar URL ni la selección previa. Un resize nunca cambia rutas ni abre automáticamente un modal.

View File

@ -84,7 +84,7 @@
},
"assetPanel": "Selected asset details",
"closeAssetPanel": "Close details panel",
"showAssetPanel": "Show details panel",
"showAssetPanel": "Show selected details",
"inventory": "Assets",
"allAssets": "All",
"filterByTemplate": "Filter assets by template",

View File

@ -84,7 +84,7 @@
},
"assetPanel": "Ficha del activo seleccionado",
"closeAssetPanel": "Cerrar ficha lateral",
"showAssetPanel": "Mostrar ficha lateral",
"showAssetPanel": "Mostrar detalle seleccionado",
"inventory": "Activos",
"allAssets": "Todo",
"filterByTemplate": "Filtrar activos por plantilla",

View File

@ -1,4 +1,5 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { useAssetDetailController } from "../controllers";
@ -10,10 +11,12 @@ export function AssetDetailDialog({
asset,
lookups,
onClose,
finalFocus,
}: {
asset: AssetDetailProps["asset"];
lookups: AssetDetailProps["lookups"];
onClose: () => void;
finalFocus?: ComponentProps<typeof FormDialog>["finalFocus"];
}) {
const { t } = useTranslation("assets");
const { inventory } = useAssetDetailController({ asset, lookups });
@ -21,6 +24,7 @@ export function AssetDetailDialog({
return (
<FormDialog
cancelLabel={t("close")}
finalFocus={finalFocus}
layout="stable"
onOpenChange={(open) => {
if (!open) {

View File

@ -59,7 +59,8 @@ export function AssetSelectionPanel({
</>
}
closeLabel={t("closeAssetPanel")}
label={t("assetPanel")}
label={`${t("assetPanel")}: ${asset.values.name}`}
layout="inline"
onClose={onClose}
summary={<AssetPanelSummary asset={asset} lookups={lookups} />}
title={

View File

@ -1,5 +1,5 @@
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail";
import { useContextualDetail } from "@repo/rdx-ui/hooks/use-contextual-detail";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
@ -11,6 +11,7 @@ import {
import { inventoryKeys } from "../../../shared/queries";
import { removeAsset } from "../../shared/api";
import { getAssetInventory } from "../api";
import { INVENTORY_DETAIL_BUDGET } from "../helpers/inventory-detail-layout";
import type { AssetInventoryPageProps } from "../types";
export function useAssetInventoryPageController({
@ -73,8 +74,10 @@ export function useAssetInventoryPageController({
setDeleteId(undefined);
},
});
const compact = useCompactDetail();
const [localViewId, setLocalViewId] = useState<string | undefined>();
const detail = useContextualDetail({
budget: INVENTORY_DETAIL_BUDGET,
initialVisible: Boolean(initialId),
});
const {
selectedId: navigationId,
selectEntity,
@ -84,9 +87,6 @@ export function useAssetInventoryPageController({
const [panelId, setPanelId] = useState(initialId);
const selectedId = routed ? panelId : navigationId;
const setSelectedId = routed ? setPanelId : selectEntity;
const [detailOpen, setDetailOpen] = useState(true);
const viewId = compact && routed ? selectedId : localViewId;
const setViewId = compact && routed ? setSelectedId : setLocalViewId;
const entityHref = (id: string) => {
const search = searchParams.toString();
@ -95,17 +95,13 @@ export function useAssetInventoryPageController({
};
const selectAsset = (id: string) => {
setDetailOpen(true);
detail.setVisible(true);
setSelectedId(id);
if (compact && !routed) {
setLocalViewId(id);
}
};
const assets = query.data?.assets ?? [];
const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : undefined;
const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId);
const deleted = assets.find((asset) => asset.id === deleteId);
return {
@ -129,14 +125,13 @@ export function useAssetInventoryPageController({
deleteId,
setDeleteId,
remove,
setViewId,
setSelectedId,
detailOpen,
setDetailOpen,
detail,
detailOpen: detail.visible && Boolean(selected),
overlayAsset: detail.visible && detail.mode === "overlay" ? selected : undefined,
selected,
edited,
workstationAsset: assets.find((asset) => asset.id === workstationAssetId),
setWorkstationAssetId,
viewed,
};
}

View File

@ -0,0 +1,8 @@
import type { ContextualDetailBudget } from "@repo/rdx-ui/lib/contextual-detail-layout";
export const INVENTORY_DETAIL_BUDGET = {
table: 800,
gap: 24,
panelMin: 320,
panelMax: 400,
} as const satisfies ContextualDetailBudget;

View File

@ -4,9 +4,10 @@ import {
EntityListHeader,
EntityRowActions,
} from "@repo/rdx-ui/components/entity-list";
import { contextualDetailColumns } from "@repo/rdx-ui/lib/contextual-detail-layout";
import { BoxIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useRef } from "react";
import { useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { CatalogError } from "../../../catalog";
@ -16,6 +17,7 @@ import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
import { AssetList } from "../../shared/ui/asset-list";
import { useAssetInventoryPageController } from "../controllers";
import { INVENTORY_DETAIL_BUDGET } from "../helpers/inventory-detail-layout";
import type { AssetInventoryPageProps } from "../types";
import { AssetWorkstationAction } from "./asset-workstation-action";
@ -28,7 +30,6 @@ function selectedRowClass(id: string, selectedId: string | undefined, detailOpen
}
export function AssetInventoryPage(props: AssetInventoryPageProps) {
const inventoryRef = useRef<HTMLDivElement>(null);
const newAssetButtonRef = useRef<HTMLButtonElement>(null);
const editorTriggerRef = useRef<HTMLElement | null>(null);
const { companyId, lookups } = props;
@ -51,19 +52,25 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
deleteId,
setDeleteId,
remove,
setViewId,
setSelectedId,
detail,
detailOpen,
setDetailOpen,
overlayAsset,
selected,
edited,
viewed,
missingView,
entityHref,
selectAsset,
} = useAssetInventoryPageController(props);
const { containerRef: inventoryRef } = detail;
const { t, i18n } = useTranslation("assets");
useLayoutEffect(() => {
if (missingView && document.activeElement === document.body) {
inventoryRef.current?.focus({ preventScroll: true });
}
}, [missingView, inventoryRef]);
function openEditor(id: string) {
// Se captura antes de que el anuncio de progreso del asistente reciba el foco.
editorTriggerRef.current =
@ -93,15 +100,28 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
}
if (missingView) {
return <EntityNotFound />;
return (
<div data-detail-open={false} data-slot="inventory-layout" ref={inventoryRef} tabIndex={-1}>
<EntityNotFound />
</div>
);
}
return (
<div
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_clamp(320px,24vw,400px)] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
data-detail-open={detailOpen && Boolean(selected)}
className="grid min-w-0 items-start"
data-detail-mode={detail.mode}
data-detail-open={detailOpen}
data-selected-asset={selected?.id}
data-slot="inventory-layout"
ref={inventoryRef}
style={{
gap: INVENTORY_DETAIL_BUDGET.gap,
gridTemplateColumns: contextualDetailColumns(
detail.mode === "inline" && detailOpen,
INVENTORY_DETAIL_BUDGET
),
}}
tabIndex={-1}
>
<div className="min-w-0 space-y-5">
@ -110,10 +130,12 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
<>
{" "}
<div className="flex flex-wrap items-center justify-end gap-2">
{!detailOpen && selected && (
{selected && (
<Button
className="hidden inventory:inline-flex"
onClick={() => setDetailOpen(true)}
className="data-[detail-open=true]:hidden"
data-detail-open={detailOpen}
onClick={() => detail.setVisible(true)}
ref={detail.reopenRef}
variant="outline"
>
{t("showAssetPanel")}
@ -217,7 +239,6 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
aria-pressed={selected?.id === asset.id && detailOpen}
className="cursor-pointer text-left font-semibold wrap-anywhere app-link text-primary hover:text-primary focus-visible:rounded-lg focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
href={entityHref(asset.id)}
onClick={() => setDetailOpen(true)}
>
{asset.values.name}
</BrowserLink>
@ -234,11 +255,11 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
canEdit={catalog.isSuccess}
companyId={companyId}
lookups={lookups}
onClose={() => setDetailOpen(false)}
onClose={() => detail.setVisible(false)}
onDelete={setDeleteId}
onEdit={openEditor}
onView={(id) => navigateTo(entityHref(id))}
open={detailOpen}
open={detailOpen && detail.mode === "inline"}
template={catalog.data?.items.find((item) => item.id === selected?.values.templateId)}
/>
{editor && catalog.data && (
@ -266,8 +287,13 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
workstation={undefined}
/>
)}
{viewed && (
<AssetDetailDialog asset={viewed} lookups={lookups} onClose={() => setViewId(undefined)} />
{overlayAsset && (
<AssetDetailDialog
asset={overlayAsset}
finalFocus={detail.finalFocus}
lookups={lookups}
onClose={() => detail.setVisible(false)}
/>
)}
<DeleteAssetDialog
asset={deleted}
@ -278,9 +304,8 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
return;
}
await remove.mutateAsync(deleteId);
setDetailOpen(false);
detail.setVisible(false);
setSelectedId(undefined);
setViewId(undefined);
}}
open={Boolean(deleted)}
/>

View File

@ -3,6 +3,10 @@ import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
import {
contextualDetailMode,
contextualDetailReducer,
} from "@repo/rdx-ui/lib/contextual-detail-layout";
import { Maybe } from "@repo/rdx-utils";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
@ -35,6 +39,7 @@ import { WorkstationSummary } from "../../workstations/shared/ui/workstation-sum
import { AssetCreateProgress } from "../create/ui/asset-create-progress";
import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
import { AssetInventoryPage } from "../list";
import { INVENTORY_DETAIL_BUDGET } from "../list/helpers/inventory-detail-layout";
import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary";
import { hasMissingAssetFields, suggestedAssetName } from "./helpers/asset-creation-values";
@ -42,6 +47,30 @@ import { AssetForm, AssetSummaryCard } from "./ui";
import { AssetCharacteristicControl } from "./ui/asset-characteristic-control";
const companyId = UniqueID.generateNewID().toPrimitive();
test("La capacidad del inventario protege el presupuesto de tabla en el límite real", () => {
for (const width of [0, 288, 980, 1143, 1143.99, Number.NaN, Number.POSITIVE_INFINITY]) {
assert.equal(contextualDetailMode(width, INVENTORY_DETAIL_BUDGET), "overlay");
}
for (const width of [1144, 1145, 1224, 2000]) {
assert.equal(contextualDetailMode(width, INVENTORY_DETAIL_BUDGET), "inline");
}
});
test("El resize conserva el contexto abierto al ganar capacidad y nunca abre un modal", () => {
const closed = { mode: "overlay", visible: false } as const;
const inlineClosed = contextualDetailReducer(closed, { type: "capacity", mode: "inline" });
assert.deepEqual(inlineClosed, { mode: "inline", visible: false });
assert.equal(contextualDetailReducer(closed, { type: "capacity", mode: "overlay" }), closed);
const opened = contextualDetailReducer(closed, { type: "visibility", visible: true });
const inline = contextualDetailReducer(opened, { type: "capacity", mode: "inline" });
assert.deepEqual(inline, { mode: "inline", visible: true });
assert.equal(contextualDetailReducer(inline, { type: "capacity", mode: "inline" }), inline);
assert.deepEqual(contextualDetailReducer(inline, { type: "capacity", mode: "overlay" }), closed);
assert.equal(contextualDetailReducer(closed, { type: "visibility", visible: false }), closed);
});
const lookups = {
sites: [],
people: [],

View File

@ -17,7 +17,7 @@
},
"resourcePanel": "Resource details",
"closeResourcePanel": "Close resource panel",
"showResourcePanel": "Show resource panel",
"showResourcePanel": "Show selected details",
"allResources": "All",
"filterByTemplate": "Filter by template",
"inventory": "Resources",

View File

@ -17,7 +17,7 @@
},
"resourcePanel": "Ficha del recurso",
"closeResourcePanel": "Cerrar panel del recurso",
"showResourcePanel": "Mostrar panel del recurso",
"showResourcePanel": "Mostrar detalle seleccionado",
"allResources": "Todo",
"filterByTemplate": "Filtrar por plantilla",
"inventory": "Recursos",

View File

@ -9,6 +9,7 @@ export function ResourceDetailDialog({
references,
relatedContent,
onClose,
finalFocus,
}: {
resource: ComponentProps<typeof ResourceDetail>["resource"];
references: NonNullable<ComponentProps<typeof ResourceDetail>["references"]>;
@ -16,12 +17,14 @@ export function ResourceDetailDialog({
| ((resource: ComponentProps<typeof ResourceDetail>["resource"]) => ReactNode)
| undefined;
onClose: () => void;
finalFocus?: ComponentProps<typeof FormDialog>["finalFocus"];
}) {
const { t } = useTranslation("resources");
return (
<FormDialog
cancelLabel={t("close")}
finalFocus={finalFocus}
layout="stable"
onOpenChange={(open) => {
if (!open) {

View File

@ -44,7 +44,8 @@ export function ResourceSelectionPanel({
</>
}
closeLabel={t("closeResourcePanel")}
label={t("resourcePanel")}
label={`${t("resourcePanel")}: ${resource.values.name}`}
layout="inline"
onClose={onClose}
summary={
<div className="flex flex-wrap items-center gap-2 text-sm">

View File

@ -4,7 +4,7 @@ import {
useEntityNavigation,
useListQuery,
} from "@metis/core/client";
import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail";
import { useContextualDetail } from "@repo/rdx-ui/hooks/use-contextual-detail";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
@ -12,6 +12,7 @@ import type { ResourceDTO } from "../../../../common/inventory";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import { inventoryApi, inventoryKeys } from "../../shared/api";
import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys";
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
import type { ResourcesPageProps } from "../types";
function initialEditor(create: boolean) {
@ -55,9 +56,11 @@ export function useResourcesPageController({
entityHref: baseEntityHref,
routed,
} = useEntityNavigation(`/companies/${companyId}/resources`, initialId);
const compact = useCompactDetail();
const detail = useContextualDetail({
budget: RESOURCE_DETAIL_BUDGET,
initialVisible: Boolean(selectedId),
});
const [localViewId, setLocalViewId] = useState<string | undefined>();
const [detailOpen, setDetailOpen] = useState(true);
const entityHref = (id: string) => {
const search = searchParams.toString();
@ -74,15 +77,35 @@ export function useResourcesPageController({
};
const selectResource = (id: string) => {
setDetailOpen(true);
detail.setVisible(true);
setSelectedId(id);
if (compact && !routed) {
setLocalViewId(id);
};
const setViewId = (id: string | undefined) => {
if (id) {
detail.captureTrigger();
if (detail.mode === "overlay") {
selectResource(id);
} else {
setLocalViewId(id);
}
} else if (explicitView) {
setLocalViewId(undefined);
detail.restoreFocus();
} else if (routed) {
// El cierre voluntario compacto conserva el contrato de navegación de Recursos.
selectEntity(undefined);
detail.restoreFocus();
} else {
detail.setVisible(false);
}
};
const viewId = compact && routed ? selectedId : localViewId;
const setViewId = compact && routed ? setSelectedId : setLocalViewId;
const openEditor = (id: string) => {
detail.captureTrigger();
setEditor(id);
};
const query = useListQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
@ -114,7 +137,9 @@ export function useResourcesPageController({
const edited = query.data?.resources.find((item) => item.id === editor);
const selected = query.data?.resources.find((item) => item.id === selectedId);
const viewed = query.data?.resources.find((item) => item.id === viewId);
const explicitView = query.data?.resources.find((item) => item.id === localViewId);
const viewed =
explicitView ?? (detail.mode === "overlay" && detail.visible ? selected : undefined);
const visible =
query.data?.resources.filter((item) => {
if (templateId && item.values.templateId !== templateId) {
@ -140,13 +165,14 @@ export function useResourcesPageController({
selectEntity(undefined);
}
}
if (viewId === id) {
if (localViewId === id) {
setLocalViewId(undefined);
}
},
search,
setSearch,
editor,
openEditor,
setEditor,
setViewId,
query,
@ -160,8 +186,8 @@ export function useResourcesPageController({
entityHref,
selected,
selectResource,
detailOpen,
setDetailOpen,
detail,
detailOpen: detail.visible && Boolean(selected) && !explicitView,
missingView: Boolean(selectedId && query.isSuccess && !selected),
visible,
loading: query.isPending || catalog.isPending,

View File

@ -0,0 +1,9 @@
import type { ContextualDetailBudget } from "@repo/rdx-ui/lib/contextual-detail-layout";
// Cuatro columnas iniciales: nombre y resumen, plantilla, estado y acciones ancladas.
export const RESOURCE_DETAIL_BUDGET = {
table: 640,
gap: 24,
panelMin: 320,
panelMax: 400,
} as const satisfies ContextualDetailBudget;

View File

@ -8,9 +8,11 @@ import {
} from "@repo/rdx-ui/components/entity-list";
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
import { contextualDetailColumns } from "@repo/rdx-ui/lib/contextual-detail-layout";
import { LayersIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
import { useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon";
@ -19,6 +21,7 @@ import { ResourceDetailDialog, ResourceSelectionPanel } from "../../detail";
import { DeleteResourceDialog } from "../../lifecycle/ui/delete-resource-dialog";
import { ResourceEditorDialog } from "../../shared";
import { useResourcesPageController } from "../controllers";
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
import type { ResourcesPageProps } from "../types";
export function ResourcesPage(props: ResourcesPageProps) {
@ -28,6 +31,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
setSearch,
editor,
setEditor,
openEditor,
setViewId,
query,
catalog,
@ -45,7 +49,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
selected,
selectResource,
detailOpen,
setDetailOpen,
detail,
deleteId,
setDeleteId,
statusMutation,
@ -54,32 +58,67 @@ export function ResourcesPage(props: ResourcesPageProps) {
const { t, i18n } = useTranslation("resources");
const tabValue = templateId ?? "all";
const deleting = query.data?.resources.find((resource) => resource.id === deleteId);
const viewedId = viewed?.id;
const previousViewedIdRef = useRef(viewedId);
useLayoutEffect(() => {
if (previousViewedIdRef.current && !viewedId) {
// Una actualización externa también puede retirar el diálogo sin ejecutar su cierre.
detail.restoreFocus();
}
previousViewedIdRef.current = viewedId;
if (missingView && document.activeElement === document.body) {
detail.containerRef.current?.focus({ preventScroll: true });
}
}, [missingView, viewedId, detail.containerRef, detail.restoreFocus]);
if (missingView) {
return <EntityNotFound />;
return (
<div
data-detail-open={false}
data-slot="inventory-layout"
ref={detail.containerRef}
tabIndex={-1}
>
<EntityNotFound />
</div>
);
}
return (
<div
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_clamp(320px,24vw,400px)] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
data-detail-open={detailOpen && Boolean(selected)}
className="grid min-w-0 items-start"
data-detail-mode={detail.mode}
data-detail-open={detailOpen}
data-selected-resource={selected?.id}
data-slot="inventory-layout"
ref={detail.containerRef}
style={{
gap: RESOURCE_DETAIL_BUDGET.gap,
gridTemplateColumns: contextualDetailColumns(
detail.mode === "inline" && detailOpen,
RESOURCE_DETAIL_BUDGET
),
}}
tabIndex={-1}
>
<div className="min-w-0 space-y-6">
<EntityListHeader
actions={
<>
{" "}
{!detailOpen && selected && (
{selected && (
<Button
className="hidden inventory:inline-flex"
onClick={() => setDetailOpen(true)}
className="data-[detail-open=true]:hidden"
data-detail-open={detailOpen}
onClick={() => detail.setVisible(true)}
ref={detail.reopenRef}
variant="outline"
>
{t("showResourcePanel")}
</Button>
)}
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
<Button disabled={!(query.data && catalog.data)} onClick={() => openEditor("new")}>
<LayersIcon aria-hidden="true" />
{t("newResource")}
</Button>{" "}
@ -134,7 +173,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
<BrowserLink
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={entityHref(item.id)}
onClick={() => setDetailOpen(true)}
onClick={() => detail.setVisible(true)}
>
{item.values.name}
</BrowserLink>
@ -188,7 +227,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
onClick: () => openEditor(item.id),
disabled: !catalog.data,
}}
>
@ -241,11 +280,11 @@ export function ResourcesPage(props: ResourcesPageProps) {
</TabsContent>
</Tabs>
</div>
{selected && detailOpen && (
{selected && detailOpen && detail.mode === "inline" && (
<ResourceSelectionPanel
canEdit={catalog.isSuccess}
onClose={() => setDetailOpen(false)}
onEdit={() => setEditor(selected.id)}
onClose={() => detail.setVisible(false)}
onEdit={() => openEditor(selected.id)}
onView={() => setViewId(selected.id)}
references={query.data?.references ?? []}
relatedContent={relatedContent}
@ -258,6 +297,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
{editor && catalog.data && (
<ResourceEditorDialog
companyId={companyId}
finalFocus={detail.triggerFinalFocus}
items={catalog.data.items}
onClose={() => setEditor(undefined)}
onSaved={(id) => {
@ -269,6 +309,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
)}
{viewed && (
<ResourceDetailDialog
finalFocus={detail.finalFocus}
onClose={() => setViewId(undefined)}
references={query.data?.references ?? []}
relatedContent={relatedContent}

View File

@ -198,8 +198,9 @@ test("Recursos: las pestañas incluyen plantillas vacías e inactivas y combinan
assert.ok(detailTable);
assert.ok(detailHtml.includes('data-detail-open="true"'));
assert.ok(detailHtml.includes('aria-label="Ficha del recurso"'));
assert.ok(detailHtml.includes('aria-label="Cerrar panel del recurso"'));
// SSR no presupone capacidad: el panel solo se monta tras medir el contenedor real.
assert.ok(detailHtml.includes('data-detail-mode="overlay"'));
assert.equal(detailHtml.includes('data-slot="selection-panel"'), false);
assert.equal(detailHtml.includes('role="dialog"'), false);
assertResourceRows(detailTable, resources, expected, search);
}
@ -468,7 +469,7 @@ test("recursos: ficha lateral compacta traducida con cierre, acciones y contenid
/>
);
assert.ok(html.includes(`aria-label="${panel}"`));
assert.ok(html.includes(`aria-label="${panel}: ${resource.values.name}"`));
assert.ok(html.includes(`aria-label="${close}"`));
assert.ok(html.includes(resource.specification.templateName));
assert.ok(html.includes(status));

View File

@ -10,12 +10,14 @@ export function ResourceEditorDialog({
companyId,
items,
onClose,
finalFocus,
onSaved,
}: {
resource: ComponentProps<typeof ResourceForm>["resource"];
companyId: string;
items: ComponentProps<typeof ResourceForm>["items"];
onClose: () => void;
finalFocus?: ComponentProps<typeof FormDialog>["finalFocus"];
onSaved: (id: string) => void;
}) {
const { t } = useTranslation("resources");
@ -23,6 +25,7 @@ export function ResourceEditorDialog({
return (
<FormDialog
cancelLabel={t("cancel")}
finalFocus={finalFocus}
formActions
layout="stable"
onOpenChange={(open) => {

View File

@ -12,6 +12,7 @@ export function SelectionPanel({
summary,
actions,
children,
layout = "responsive",
}: {
label: string;
title: ReactNode;
@ -20,11 +21,13 @@ export function SelectionPanel({
summary?: ReactNode;
actions: ReactNode;
children: ReactNode;
layout?: "responsive" | "inline";
}) {
return (
<aside
aria-label={label}
className="@container hidden min-w-0 grid-cols-[minmax(0,1fr)] gap-4 rounded-panel border border-border bg-card p-4 [overflow-wrap:anywhere] inventory:grid"
className={`@container min-w-0 grid-cols-[minmax(0,1fr)] gap-4 rounded-panel border border-border bg-card p-4 [overflow-wrap:anywhere] ${layout === "inline" ? "grid" : "hidden inventory:grid"}`}
data-slot="selection-panel"
>
<header className="space-y-2">
<div className="flex items-start justify-between gap-3">

View File

@ -0,0 +1,159 @@
import { useLayoutEffect, useReducer, useRef } from "react";
import type { ContextualDetailBudget } from "../lib/contextual-detail-layout";
import { contextualDetailMode, contextualDetailReducer } from "../lib/contextual-detail-layout";
/** Mide la caja exterior estable; selección y datos siguen perteneciendo al consumidor. */
export function useContextualDetail({
budget,
initialVisible = false,
}: {
budget: ContextualDetailBudget;
initialVisible?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const reopenRef = useRef<HTMLButtonElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const restorePanelFocusRef = useRef(false);
const transferDialogFocusRef = useRef(false);
const restoreFrameRef = useRef<number | undefined>(undefined);
const [state, dispatch] = useReducer(contextualDetailReducer, {
mode: "overlay",
visible: initialVisible,
});
useLayoutEffect(() => {
const root = containerRef.current;
if (!root) {
return;
}
let mode = root.dataset.detailMode === "inline" ? "inline" : "overlay";
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const next = contextualDetailMode(entry.contentRect.width, budget);
if (next === mode) {
return;
}
if (
mode === "inline" &&
root.querySelector(":scope > [data-slot=selection-panel]")?.contains(document.activeElement)
) {
restorePanelFocusRef.current = true;
}
if (mode === "overlay" && root.dataset.detailOpen === "true") {
transferDialogFocusRef.current = true;
}
mode = next;
dispatch({ type: "capacity", mode: next });
});
observer.observe(root);
return () => {
observer.disconnect();
if (restoreFrameRef.current !== undefined) {
cancelAnimationFrame(restoreFrameRef.current);
restoreFrameRef.current = undefined;
}
};
}, [budget]);
useLayoutEffect(() => {
if (!state.visible && restorePanelFocusRef.current) {
restorePanelFocusRef.current = false;
(reopenRef.current ?? containerRef.current)?.focus({ preventScroll: true });
}
if (state.mode === "inline" && state.visible && transferDialogFocusRef.current) {
transferDialogFocusRef.current = false;
// Tras la liberación de modalidad y guardas de Base UI, enfocar el control equivalente.
const frame = requestAnimationFrame(() => {
containerRef.current
?.querySelector<HTMLButtonElement>("[data-slot=selection-panel] header button")
?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}
}, [state]);
function setVisible(visible: boolean) {
if (visible) {
captureTrigger();
} else if (
containerRef.current
?.querySelector(":scope > [data-slot=selection-panel]")
?.contains(document.activeElement)
) {
restorePanelFocusRef.current = true;
}
dispatch({ type: "visibility", visible });
if (!visible && state.mode === "overlay") {
// finalFocus puede consultar el disparador antes de que vuelva a ser visible en el commit.
restoreFocus();
}
}
function captureTrigger() {
const active = document.activeElement;
triggerRef.current = active instanceof HTMLElement && active !== document.body ? active : null;
}
function triggerFinalFocus() {
const root = containerRef.current;
const trigger = triggerRef.current;
return (
(trigger?.isConnected &&
trigger.getClientRects().length > 0 &&
!trigger.matches(":disabled, [aria-disabled=true]")
? trigger
: null) ??
(reopenRef.current?.getClientRects().length ? reopenRef.current : null) ??
root?.querySelector<HTMLInputElement>("input[data-quick-search]:enabled") ??
root ??
true
);
}
function finalFocus() {
// El modal contextual que pasa a panel termina en su control de cierre equivalente.
return (
containerRef.current?.querySelector<HTMLButtonElement>(
"[data-slot=selection-panel] header button"
) ?? triggerFinalFocus()
);
}
function restoreFocus() {
if (restoreFrameRef.current !== undefined) {
cancelAnimationFrame(restoreFrameRef.current);
}
// Un diálogo explícito puede retirar y reponer el panel sin cambiar el modo de capacidad.
restoreFrameRef.current = requestAnimationFrame(() => {
restoreFrameRef.current = undefined;
const target = finalFocus();
if (target instanceof HTMLElement && target.isConnected) {
target.focus({ preventScroll: true });
}
});
}
return {
...state,
containerRef,
reopenRef,
setVisible,
captureTrigger,
finalFocus,
triggerFinalFocus,
restoreFocus,
};
}

View File

@ -0,0 +1,52 @@
export type ContextualDetailBudget = Readonly<{
table: number;
gap: number;
panelMin: number;
panelMax: number;
}>;
export type ContextualDetailMode = "inline" | "overlay";
export type ContextualDetailState = {
mode: ContextualDetailMode;
visible: boolean;
};
export type ContextualDetailEvent =
| { type: "capacity"; mode: ContextualDetailMode }
| { type: "visibility"; visible: boolean };
export function contextualDetailMode(
width: number,
budget: ContextualDetailBudget
): ContextualDetailMode {
const { table, gap, panelMin } = budget;
return Number.isFinite(width) && width >= table + gap + panelMin ? "inline" : "overlay";
}
export function contextualDetailReducer(
state: ContextualDetailState,
event: ContextualDetailEvent
): ContextualDetailState {
if (event.type === "visibility") {
return state.visible === event.visible ? state : { ...state, visible: event.visible };
}
if (state.mode === event.mode) {
return state;
}
// Perder capacidad nunca convierte un panel en un modal no solicitado.
return { mode: event.mode, visible: event.mode === "inline" && state.visible };
}
export function contextualDetailColumns(
inlineVisible: boolean,
budget: ContextualDetailBudget
): string {
const { table, gap, panelMin, panelMax } = budget;
return inlineVisible
? `minmax(0, 1fr) clamp(${panelMin}px, calc(100% - ${table + gap}px), ${panelMax}px)`
: "minmax(0, 1fr)";
}

View File

@ -6,6 +6,38 @@ import { renderToStaticMarkup } from "react-dom/server";
import { FormStepActions } from "./components/form-step-actions";
import { OptionPickerList } from "./components/option-picker-list";
import { OptionSelectionField } from "./components/option-selection-field";
import { contextualDetailMode, contextualDetailReducer } from "./lib/contextual-detail-layout";
test("La capacidad contextual admite presupuestos propios sin acoplarlos al viewport", () => {
const assets = { table: 800, gap: 24, panelMin: 320, panelMax: 400 };
const resources = { table: 640, gap: 24, panelMin: 320, panelMax: 400 };
for (const [budget, limit] of [
[assets, 1144],
[resources, 984],
] as const) {
assert.equal(contextualDetailMode(limit - 1, budget), "overlay");
assert.equal(contextualDetailMode(limit, budget), "inline");
assert.equal(contextualDetailMode(limit + 1, budget), "inline");
assert.equal(contextualDetailMode(320, budget), "overlay");
assert.equal(contextualDetailMode(Number.NaN, budget), "overlay");
}
});
test("La pérdida de capacidad cierra sin abrir modal y los eventos repetidos no publican estado", () => {
const closed = { mode: "overlay", visible: false } as const;
const opened = contextualDetailReducer(closed, { type: "visibility", visible: true });
const inline = contextualDetailReducer(opened, { type: "capacity", mode: "inline" });
assert.deepEqual(inline, { mode: "inline", visible: true });
assert.deepEqual(contextualDetailReducer(inline, { type: "capacity", mode: "overlay" }), closed);
assert.deepEqual(contextualDetailReducer(closed, { type: "capacity", mode: "inline" }), {
mode: "inline",
visible: false,
});
assert.equal(contextualDetailReducer(inline, { type: "capacity", mode: "inline" }), inline);
assert.equal(contextualDetailReducer(closed, { type: "visibility", visible: false }), closed);
});
const options = [
{ id: "available", label: "Ana", description: "Administración" },

View File

@ -40,6 +40,7 @@ class VisualUiTests(unittest.TestCase):
if not cls.output.is_relative_to(ROOT / ".artifacts/playwright"):
raise RuntimeError("Las evidencias deben quedar dentro de .artifacts/playwright.")
cls.output.mkdir(parents=True, exist_ok=True)
cls.resources = report.get("resources", [])
cls.origin = report["origin"]
cls.company = "/companies/" + report["companyId"]
cls.results = json.loads((cls.output / "measurements.json").read_text()) if (cls.output / "measurements.json").exists() else []
@ -90,7 +91,7 @@ class VisualUiTests(unittest.TestCase):
self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))}); return true;")
def test_selected_row_contrast(self):
self.resize(1440, 900)
self.resize(1920, 1080)
self.navigate("assets")
self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).waitFor();return true;")
values = []
@ -114,7 +115,7 @@ class VisualUiTests(unittest.TestCase):
self.assertTrue(self.code("return await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).evaluate(e=>e.matches(':focus-visible'));"))
values.extend(measurements)
self.record("contrast-" + state, measurements)
self.capture("contrast-" + state + "-1440")
self.capture("contrast-" + state + "-1920")
self.assertTrue(all(x["ratio"] >= 4.5 for x in values), values)
def test_wizard_geometry(self):
@ -529,5 +530,407 @@ class VisualUiTests(unittest.TestCase):
self.record('responsive-company-switch',{'second':second,'returnedTo':self.company})
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}})()""")
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;")
self.settle()
def inventory_width(self, width):
self.code("await page.locator('[data-slot=inventory-layout]').evaluate((e,w)=>e.style.width=w+'px',"+str(width)+");return true;")
self.settle()
def test_inventory_container_capacity(self):
self.resize(1920,1080)
self.navigate('assets')
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)
value=self.inventory_metrics()
self.assertEqual(value['mode'],mode)
self.assertEqual(value['width'],width)
self.assertTrue(value['sameTable'])
self.assertEqual(value['dialogs'],0)
if not value.get('selected'):
self.inventory_select()
elif value['open']=='false':
self.code("await page.getByRole('button',{name:'Mostrar detalle seleccionado',exact:true}).click();return true;")
self.settle()
value=self.inventory_metrics()
if mode=='inline':
self.assertGreaterEqual(value['table'],798) # Borde exterior de EntityTable: 2 px.
self.assertGreaterEqual(value['panel'],320)
self.assertLessEqual(value['panel'],400)
self.assertEqual(value['gap'],24)
self.assertEqual(value['dialogs'],0)
else:
self.assertEqual(value['panel'],0)
self.assertEqual(value['dialogs'],1)
self.record('inventory-boundary-'+str(width),value)
self.capture('inventory-boundary-'+str(width))
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.inventory_width(1144)
value=self.inventory_metrics()
self.assertEqual(value['open'],'false')
self.assertEqual(value['panel'],0)
self.assertEqual(value['dialogs'],0)
self.assertTrue(value.get('selected'))
self.code("await page.locator('[data-slot=inventory-layout]').evaluate(e=>e.style.removeProperty('width'));return true;")
def test_inventory_resize_focus_and_actions(self):
self.resize(1920,1080)
self.navigate('assets')
self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).waitFor();await page.evaluate(()=>window.inventoryTable=document.querySelector('main table'));return true;")
self.settle()
initial=self.inventory_metrics()
self.record('inventory-focus-initial',initial)
self.assertEqual(initial['mode'],'inline',initial)
self.inventory_select()
self.assertGreater(self.inventory_metrics()['panel'],0)
self.code("await page.locator('[data-slot=inventory-layout]>aside').getByRole('button',{name:'Editar',exact:true}).focus();return true;")
selected=self.inventory_metrics()['selected']
self.resize(1100,800)
value=self.inventory_metrics()
self.assertEqual(value['selected'],selected)
self.assertEqual(value['focus'],'Mostrar detalle seleccionado')
self.assertEqual(value['dialogs'],0)
self.assertTrue(value['sameTable'])
self.code("await page.keyboard.press('Enter');await page.getByRole('dialog',{name:'AUDIT-PORT-01',exact:true}).waitFor();return true;")
self.resize(1920,1080)
self.code("await page.waitForFunction(()=>document.activeElement?.closest('[data-slot=inventory-layout]>aside'));return true;")
value=self.inventory_metrics()
self.assertEqual(value['dialogs'],0)
self.assertFalse(value['inert'])
self.assertFalse(value['bodyFocus'])
self.assertTrue(value['sameTable'])
self.record('inventory-overlay-to-inline-focus',value)
self.capture('inventory-overlay-to-inline-focus')
self.code("await page.getByRole('row').filter({has:page.getByRole('link',{name:'AUDIT-PORT-01',exact:true})}).getByRole('button',{name:'Editar',exact:true}).focus();await page.evaluate(()=>window.inventoryAction=document.activeElement);return true;")
self.resize(1100,800)
self.assertTrue(self.evaluate('document.activeElement===window.inventoryAction'))
self.assertEqual(self.inventory_metrics()['dialogs'],0)
self.code("await page.keyboard.press('Enter');await page.getByRole('dialog',{name:'Editar activo',exact:true}).waitFor();return true;")
self.capture('inventory-edit-after-resize')
self.code("await page.getByRole('dialog').getByRole('combobox',{name:'Sede',exact:true}).click();await page.getByRole('listbox').waitFor();return true;")
self.capture('inventory-edit-nested-select')
self.code("await page.keyboard.press('Escape');await page.getByRole('listbox').waitFor({state:'hidden'});return true;")
self.code("await page.getByRole('dialog').getByRole('button',{name:'Cancelar',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.assertTrue(self.evaluate('document.activeElement===window.inventoryAction'))
self.code("await page.getByRole('button',{name:'Mostrar detalle seleccionado',exact:true}).click();return true;")
self.settle()
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});await page.waitForFunction(()=>document.activeElement.textContent==='Mostrar detalle seleccionado');return true;")
self.resize(1920,1080)
self.assertEqual(self.inventory_metrics()['panel'],0)
self.code("await page.getByRole('button',{name:'Mostrar detalle seleccionado',exact:true}).click();await page.locator('[data-slot=inventory-layout]>aside').getByRole('button',{name:'Ficha e historial',exact:true}).click();await page.waitForURL('**/assets/*');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),0)
self.capture('inventory-full-detail-after-panel')
def test_inventory_shell_capacity_changes(self):
self.resize(1660,900)
self.navigate('assets')
self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).waitFor();return true;")
self.assertEqual(self.inventory_metrics()['mode'],'overlay')
self.code("await page.getByRole('button',{name:'Contraer menú',exact:true}).click();return true;")
self.settle()
self.assertEqual(self.inventory_metrics()['mode'],'inline')
self.inventory_select()
self.code("await page.getByRole('button',{name:'Expandir menú',exact:true}).click();return true;")
self.settle()
self.assertEqual(self.inventory_metrics()['mode'],'overlay')
self.assertEqual(self.inventory_metrics()['dialogs'],0)
self.resize(1366,768)
self.code("await page.getByRole('button',{name:'Ocultar secciones',exact:true}).click();return true;")
self.settle()
self.assertEqual(self.inventory_metrics()['mode'],'inline')
self.code("await page.getByRole('button',{name:/^Secciones de /}).click();await page.getByRole('dialog').getByRole('button',{name:'Fijar secciones',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.settle()
value=self.inventory_metrics()
self.assertEqual(value['mode'],'overlay')
self.assertEqual(value['dialogs'],0)
self.record('inventory-shell-capacity-changes',value)
self.code("await page.evaluate(()=>localStorage.removeItem('metis-desk:sidebar-collapsed'));return true;")
def test_inventory_preserves_table_state_and_scroll(self):
self.resize(1920,1080)
self.code("""await page.route('**/api/assets/*',async route=>{if(route.request().method()!=='GET')return route.fallback();const response=await route.fetch();const data=await response.json();if(data.assets?.length===1){const asset=data.assets[0];data.assets=[asset,...Array.from({length:40},(_,i)=>({...asset,id:crypto.randomUUID(),values:{...asset.values,name:'AUDIT-PORT-'+String(i+2).padStart(2,'0')}}))]}await route.fulfill({response,json:data})});return true;""")
try:
self.navigate('assets')
self.inventory_width(1144)
self.inventory_select()
self.code("await page.getByRole('tab',{name:'Portátil',exact:true}).click();await page.getByRole('textbox',{name:'Buscar',exact:true}).fill('AUDIT');await page.getByRole('columnheader').getByRole('button',{name:/^Nombre/}).click();await page.getByRole('button',{name:'Página siguiente',exact:true}).click();await page.getByRole('button',{name:'Columnas',exact:true}).click();const d=page.getByRole('dialog',{name:'Columnas',exact:true});for(const c of await d.getByRole('checkbox').all()){if(await c.isEnabled())await c.check()}await page.keyboard.press('Escape');await d.waitFor({state:'hidden'});return true;")
self.settle()
self.code("await page.evaluate(()=>{window.inventoryTable=document.querySelector('main table');const sc=window.inventoryTable.closest('[data-slot=table-container]');sc.scrollLeft=150;const root=document.querySelector('[data-slot=workspace-scroll]');root.scrollTop=160;window.inventoryScroll={sc,root,x:sc.scrollLeft,y:root.scrollTop};window.inventoryRows=[...window.inventoryTable.querySelectorAll('tbody tr')].map(r=>r.textContent);window.inventoryColumns=localStorage.getItem('asset-inventory-columns')});return true;")
before=self.evaluate("{x:window.inventoryScroll.x,y:window.inventoryScroll.y,sort:document.querySelector('th[aria-sort]')?.getAttribute('aria-sort'),page:document.querySelector('main [aria-current=page]')?.textContent}")
self.assertGreater(before['x'],0)
self.assertGreater(before['y'],0)
for width in [1143,1144,1145,1143]:
self.inventory_width(width)
# El resize no vuelve a abrir el detalle cerrado en la primera pérdida de capacidad.
value=self.evaluate("{same:document.querySelector('main table')===window.inventoryTable,x:window.inventoryScroll.sc.scrollLeft,y:window.inventoryScroll.root.scrollTop,rows:JSON.stringify([...window.inventoryTable.querySelectorAll('tbody tr')].map(r=>r.textContent))===JSON.stringify(window.inventoryRows),columns:localStorage.getItem('asset-inventory-columns')===window.inventoryColumns,search:document.querySelector('input[data-quick-search]').value,sort:document.querySelector('th[aria-sort]')?.getAttribute('aria-sort'),page:document.querySelector('main [aria-current=page]')?.textContent,tab:document.querySelector('[role=tab][aria-selected=true]').textContent,dialogs:document.querySelectorAll('[role=dialog]').length}")
self.assertTrue(value['same'])
self.assertTrue(value['rows'])
self.assertTrue(value['columns'])
self.assertEqual(value['search'],'AUDIT')
self.assertEqual(value['sort'],before['sort'])
self.assertEqual(value['page'],before['page'])
self.assertEqual(value['tab'],'Portátil')
self.assertEqual(value['x'],before['x'])
self.assertEqual(value['y'],before['y'])
self.assertEqual(value['dialogs'],0)
self.record('inventory-table-state-'+str(width),value)
self.capture('inventory-table-state-scroll')
finally:
self.code("await page.unroute('**/api/assets/*');await page.evaluate(()=>{localStorage.removeItem('asset-inventory-columns');document.querySelector('[data-slot=inventory-layout]')?.style.removeProperty('width');delete window.inventoryTable});return true;")
def test_inventory_missing_selection_and_native_link(self):
self.resize(1920,1080)
self.navigate('assets')
self.inventory_select()
self.code("await page.getByRole('link',{name:'AUDIT-PORT-01',exact:true}).click();await page.waitForURL('**/assets/*');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),0)
self.navigate('assets')
self.inventory_select()
# Simula desaparición externa en respuestas HTTP; no borra el activo de la instalación.
self.code("await page.route('**/api/assets/*',async route=>{const response=await route.fetch();const data=await response.json();if(data.assets)data.assets=[];await route.fulfill({response,json:data})});await page.evaluate(()=>{window.dispatchEvent(new Event('visibilitychange'))});return true;")
try:
self.code("await page.getByText('No se ha encontrado el registro en este ámbito.',{exact:true}).waitFor({timeout:5000});return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),0)
self.assertFalse(self.evaluate('document.activeElement===document.body'))
self.capture('inventory-selection-disappeared')
finally:
self.code("await page.unroute('**/api/assets/*');return true;")
def resource_select(self, name='AUDIT-APP-01', source='row'):
link="page.getByRole('link',{name:"+json.dumps(name)+",exact:true})"
self.code("await "+link+".waitFor();"+("await "+link+".click();" if source=='name' else "await page.getByRole('row').filter({has:"+link+"}).locator('td').nth(1).click();")+"return true;")
self.settle()
def test_resource_capacity_and_visibility(self):
self.resize(1920,1080)
self.navigate('resources')
self.code("await page.getByRole('link',{name:'AUDIT-APP-01',exact:true}).waitFor();await page.evaluate(()=>window.inventoryTable=document.querySelector('main table'));return true;")
try:
for width,mode in [(983,'overlay'),(984,'inline'),(985,'inline'),(1064,'inline')]:
self.inventory_width(width)
value=self.inventory_metrics()
if not value.get('selected'):
self.resource_select()
value=self.inventory_metrics()
self.assertEqual(value['mode'],mode)
self.assertEqual(value['width'],width)
self.assertTrue(value['sameTable'])
self.assertEqual(value['dialogs'],1 if mode=='overlay' else 0)
if mode=='inline':
self.assertGreaterEqual(value['table'],638)
self.assertGreaterEqual(value['panel'],320)
self.assertLessEqual(value['panel'],400)
self.assertEqual(value['gap'],24)
self.record('resource-capacity-'+str(width),value)
self.capture('resource-capacity-'+str(width))
self.code("await page.locator('[data-slot=selection-panel]').getByRole('button',{name:'Editar',exact:true}).focus();return true;")
self.inventory_width(983)
value=self.inventory_metrics()
self.assertEqual(value['dialogs'],0)
self.assertEqual(value['open'],'false')
self.assertEqual(value['focus'],'Mostrar detalle seleccionado')
self.assertTrue(value.get('selected'))
self.inventory_width(984)
self.assertEqual(self.inventory_metrics()['panel'],0)
self.code("await page.getByRole('button',{name:'Mostrar detalle seleccionado',exact:true}).click();return true;")
self.settle()
self.assertEqual(self.inventory_metrics()['panel'],320)
self.code("await page.getByRole('row').filter({has:page.getByRole('link',{name:'AUDIT-APP-01',exact:true})}).getByRole('button',{name:'Editar',exact:true}).focus();await page.evaluate(()=>window.resourceAction=document.activeElement);return true;")
self.inventory_width(983)
self.assertTrue(self.evaluate('document.activeElement===window.resourceAction'))
finally:
self.code("await page.locator('[data-slot=inventory-layout]').evaluate(e=>e.style.removeProperty('width'));return true;")
self.resize(1660,900)
self.code("await page.getByRole('button',{name:'Contraer menú',exact:true}).click();return true;")
self.settle()
self.assertTrue(self.inventory_metrics()['sameTable'])
self.assertEqual(self.inventory_metrics()['dialogs'],0)
self.code("await page.getByRole('button',{name:'Expandir menú',exact:true}).click();await page.evaluate(()=>localStorage.removeItem('metis-desk:sidebar-collapsed'));return true;")
self.resize(1160,800)
self.code("await page.getByRole('button',{name:'Ocultar secciones',exact:true}).click();return true;")
self.settle()
self.assertEqual(self.inventory_metrics()['mode'],'inline')
self.assertEqual(self.inventory_metrics()['panel'],0)
self.code("await page.getByRole('button',{name:'Mostrar detalle seleccionado',exact:true}).click();await page.getByRole('button',{name:/^Secciones de /}).click();await page.getByRole('dialog').getByRole('button',{name:'Fijar secciones',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.settle()
value=self.inventory_metrics()
self.assertEqual(value['mode'],'overlay')
self.assertEqual(value['dialogs'],0)
self.assertTrue(value['sameTable'])
self.record('resource-shell-capacity-changes',value)
def test_resource_routes_and_explicit_detail(self):
self.resize(1920,1080)
self.navigate('resources')
self.code("await page.getByRole('link',{name:'AUDIT-APP-01',exact:true}).waitFor();await page.evaluate(()=>window.inventoryTable=document.querySelector('main table'));return true;")
self.resource_select(source='name')
self.assertTrue(self.inventory_metrics()['sameTable'])
selected=self.inventory_metrics()['selected']
path=self.evaluate('location.pathname')
self.assertTrue(path.endswith('/'+selected))
self.code("await page.locator('[data-slot=selection-panel]').getByRole('button',{name:'Ficha e historial',exact:true}).click();await page.getByRole('dialog',{name:'AUDIT-APP-01',exact:true}).waitFor();return true;")
self.assertEqual(self.code("return await page.locator('[data-slot=selection-panel]').count();"),0)
self.assertEqual(self.evaluate('location.pathname'),path)
self.capture('resource-explicit-dialog-1920')
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.settle()
self.assertGreater(self.inventory_metrics()['panel'],0)
self.assertFalse(self.inventory_metrics()['bodyFocus'])
self.resize(1100,800)
self.assertEqual(self.inventory_metrics()['dialogs'],0)
self.code("await page.getByRole('button',{name:'Mostrar detalle seleccionado',exact:true}).focus();await page.keyboard.press('Enter');await page.getByRole('dialog').waitFor();return true;")
self.resize(1920,1080)
self.code("await page.waitForFunction(()=>document.activeElement.closest('[data-slot=selection-panel]'));return true;")
value=self.inventory_metrics()
self.assertEqual(value['dialogs'],0)
self.assertFalse(value['inert'])
self.record('resource-overlay-to-inline',value)
self.capture('resource-overlay-to-inline-focus')
self.resize(1100,800)
self.navigate('resources')
self.code("await page.getByRole('textbox',{name:'Buscar',exact:true}).fill('AUDIT');return true;")
self.resource_select(source='name')
self.assertEqual(self.inventory_metrics()['dialogs'],1)
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});await page.waitForURL('**/resources?q=AUDIT');return true;")
self.assertFalse(self.inventory_metrics().get('selected'))
self.code("await page.goForward();await page.getByRole('dialog',{name:'AUDIT-APP-01',exact:true}).waitFor();return true;")
self.assertEqual(self.inventory_metrics()['selected'],selected)
self.capture('resource-forward-contextual-dialog')
def test_resource_state_and_lifecycle(self):
self.resize(1920,1080)
self.navigate('resources')
self.resource_select()
self.code("await page.getByRole('tab',{name:'Aplicación',exact:true}).click();await page.getByRole('textbox',{name:'Buscar',exact:true}).fill('AUDIT-APP');await page.getByRole('columnheader').getByRole('button',{name:/^Nombre/}).click();await page.getByRole('button',{name:'Página siguiente',exact:true}).click();await page.getByRole('button',{name:'Columnas',exact:true}).click();await page.getByRole('dialog').getByRole('checkbox',{name:'Estado',exact:true}).uncheck();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.inventory_width(984)
self.settle()
before=self.code("return await page.evaluate(()=>{window.resourceTable=document.querySelector('main table');window.resourceScroll=document.querySelector('[data-slot=workspace-scroll]');window.resourceScroll.scrollTop=100;return {y:window.resourceScroll.scrollTop,url:location.href,rows:[...window.resourceTable.querySelectorAll('tbody tr')].map(e=>e.textContent),headers:[...window.resourceTable.querySelectorAll('th')].map(e=>e.textContent),page:document.querySelector('main [aria-current=page]').textContent}});")
for width in [983,984,985]:
self.inventory_width(width)
value=self.evaluate("{same:document.querySelector('main table')===window.resourceTable,y:window.resourceScroll.scrollTop,url:location.href,rows:[...window.resourceTable.querySelectorAll('tbody tr')].map(e=>e.textContent),headers:[...window.resourceTable.querySelectorAll('th')].map(e=>e.textContent),page:document.querySelector('main [aria-current=page]').textContent,search:document.querySelector('input[data-quick-search]').value,tab:document.querySelector('[role=tab][aria-selected=true]').textContent}")
self.assertTrue(value['same'])
for key in ['url','rows','headers','page']:
self.assertEqual(value[key],before[key])
self.assertEqual(value['search'],'AUDIT-APP')
self.assertEqual(value['tab'],'Aplicación')
self.record('resource-state-'+str(width),value)
self.capture('resource-filtered-page-and-columns')
self.resize(1920,768)
self.code("await page.getByRole('button',{name:'Página anterior',exact:true}).click();await page.getByRole('button',{name:'Mostrar detalle seleccionado',exact:true}).click();return true;")
self.inventory_width(984)
self.code("await page.evaluate(()=>document.querySelector('[data-slot=workspace-scroll]').scrollTop=50);return true;")
self.assertEqual(self.evaluate("document.querySelector('[data-slot=workspace-scroll]').scrollTop"),50)
self.inventory_width(983)
self.assertEqual(self.evaluate("document.querySelector('[data-slot=workspace-scroll]').scrollTop"),50)
self.record('resource-vertical-scroll-preserved',self.inventory_metrics())
self.code("await page.getByRole('button',{name:'Página siguiente',exact:true}).click();await page.getByRole('button',{name:'Columnas',exact:true}).click();await page.getByRole('dialog').getByRole('checkbox',{name:'Estado',exact:true}).check();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});await page.locator('[data-slot=inventory-layout]').evaluate(e=>e.style.removeProperty('width'));return true;")
self.resize(320,640)
self.code("await page.evaluate(()=>{const sc=document.querySelector('main table').closest('[data-slot=table-container]');sc.scrollLeft=10;window.resourceX=sc.scrollLeft});return true;")
self.assertGreater(self.evaluate('window.resourceX'),0)
self.resize(321,640)
self.assertEqual(self.evaluate("document.querySelector('main table').closest('[data-slot=table-container]').scrollLeft"),10)
self.assertFalse(self.evaluate('document.documentElement.scrollWidth>innerWidth'))
self.resize(1920,1080)
self.code("const row=page.getByRole('row').filter({has:page.getByRole('link',{name:'AUDIT-APP-01',exact:true})});await row.getByRole('button',{name:'Acciones',exact:true}).click();return true;")
self.assertTrue(self.code("return await page.getByRole('menuitem',{name:'Desactivar recurso',exact:true}).isVisible();"))
self.code("await page.getByRole('menuitem',{name:'Eliminar definitivamente',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancelar',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.assertFalse(self.evaluate('document.activeElement===document.body'))
def test_resource_editor_focus_and_nested(self):
self.resize(1920,1080)
self.navigate('resources')
self.code("await page.getByRole('tab',{name:'Licencia de software',exact:true}).click();return true;")
self.resource_select('AUDIT-LICENSE — Suite empresarial de pruebas')
self.code("await page.locator('[data-slot=selection-panel]').getByRole('button',{name:'Editar',exact:true}).click();await page.getByRole('dialog').getByRole('textbox',{name:/^Nombre/}).fill('Borrador de recurso sin guardar');await page.getByRole('dialog').getByRole('combobox',{name:'Modalidad de licencia',exact:true}).click();return true;")
self.resize(1100,800)
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('textbox',{name:/^Nombre/}).inputValue();"),'Borrador de recurso sin guardar')
self.capture('resource-editor-resize-with-select')
self.code("if(await page.getByRole('listbox').count()){await page.keyboard.press('Escape');await page.getByRole('listbox').waitFor({state:'hidden'})}await page.getByRole('dialog').getByRole('button',{name:'Cancelar',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.settle()
value=self.inventory_metrics()
self.assertEqual(value['dialogs'],0)
self.assertEqual(value['focus'],'Mostrar detalle seleccionado')
self.record('resource-editor-removed-trigger',value)
self.navigate('resources')
self.code("await page.getByRole('row').filter({has:page.getByRole('link',{name:'AUDIT-APP-01',exact:true})}).getByRole('button',{name:'Editar',exact:true}).focus();await page.evaluate(()=>window.resourceEdit=document.activeElement);await page.keyboard.press('Enter');await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.assertTrue(self.evaluate('document.activeElement===window.resourceEdit'))
def test_resource_locale_and_related(self):
name=next(row['name'] for row in self.resources if row['kind']=='shared_folder')
self.resize(1920,1080)
self.navigate('resources')
self.code("await page.getByRole('tab',{name:'Carpeta compartida',exact:true}).click();return true;")
self.resource_select(name,source='name')
self.assertEqual(self.code("return await page.locator('[data-slot=selection-panel]').getAttribute('aria-label');"),'Ficha del recurso: '+name)
self.code("await page.locator('[data-slot=selection-panel] a[href*=\"/access?\"]').first().click();await page.waitForURL('**/access?**');return true;")
self.capture('resource-related-existing-route')
self.code("await page.goBack();await page.locator('[data-slot=selection-panel]').waitFor();await page.locator('summary[aria-label=\"Menú de usuario\"]').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.assertEqual(self.code("return await page.locator('[data-slot=selection-panel]').getAttribute('aria-label');"),'Resource details: '+name)
self.capture('resource-long-name-english-inline')
for width,height in [(390,844),(320,640)]:
self.resize(width,height)
self.code("await page.getByRole('button',{name:'Show selected details',exact:true}).click();await page.getByRole('dialog').waitFor();return true;")
self.assertFalse(self.evaluate('document.documentElement.scrollWidth>innerWidth'))
self.capture('resource-long-name-english-'+str(width))
self.resize(1920,1080)
self.code("await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
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;")
def test_resource_external_disappearance(self):
self.resize(1920,1080)
self.navigate('resources')
self.resource_select()
selected=self.inventory_metrics()['selected']
self.code("await page.getByRole('row').filter({has:page.getByRole('link',{name:'AUDIT-APP-02',exact:true})}).getByRole('button',{name:'Acciones',exact:true}).click();await page.getByRole('menuitem',{name:'Ficha e historial',exact:true}).click();await page.getByRole('dialog',{name:'AUDIT-APP-02',exact:true}).waitFor();return true;")
# Solo transforma la respuesta leída por este navegador; no elimina datos del entorno.
self.code("await page.route('**/api/resources/*',async route=>{const response=await route.fetch();const data=await response.json();if(data.resources)data.resources=data.resources.filter(r=>r.values.name!=='AUDIT-APP-02');await route.fulfill({response,json:data})});await page.evaluate(()=>window.dispatchEvent(new Event('visibilitychange')));return true;")
try:
self.code("await page.getByRole('dialog').waitFor({state:'hidden'});await page.locator('[data-slot=selection-panel]').waitFor();return true;")
self.settle()
self.assertEqual(self.inventory_metrics()['selected'],selected)
self.assertFalse(self.evaluate('document.activeElement===document.body'))
self.assertTrue(self.evaluate("Boolean(document.activeElement.closest('[data-slot=selection-panel]'))"))
self.capture('resource-explicit-view-disappeared')
self.code("await page.unroute('**/api/resources/*');await page.route('**/api/resources/*',async route=>{const response=await route.fetch();const data=await response.json();if(data.resources)data.resources=[];await route.fulfill({response,json:data})});await page.evaluate(()=>window.dispatchEvent(new Event('visibilitychange')));return true;")
self.code("await page.getByText('No se ha encontrado el registro en este ámbito.',{exact:true}).waitFor();return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').count();"),0)
self.assertFalse(self.evaluate('document.activeElement===document.body'))
self.assertTrue(self.evaluate("document.activeElement===document.querySelector('[data-slot=inventory-layout]')"))
self.capture('resource-selected-disappeared')
finally:
self.code("await page.unroute('**/api/resources/*');return true;")
def test_resource_observer_cleanup(self):
self.code("""await page.addInitScript(()=>{const Native=window.ResizeObserver;window.resourceObservers={active:0,observed:0,disconnected:0};window.ResizeObserver=class extends Native{observe(target,options){if(target.dataset.slot==='inventory-layout'&&!this.inventory){this.inventory=true;window.resourceObservers.active++;window.resourceObservers.observed++}super.observe(target,options)}disconnect(){if(this.inventory){this.inventory=false;window.resourceObservers.active--;window.resourceObservers.disconnected++}super.disconnect()}}});return true;""")
self.resize(1920,1080)
self.navigate('resources')
self.code("await page.getByRole('link',{name:'AUDIT-APP-01',exact:true}).waitFor();return true;")
self.settle()
self.assertEqual(self.evaluate('window.resourceObservers.active'),1)
self.inventory_width(984)
self.code("await page.evaluate(()=>{window.resourceModes=[];window.resourceModeObserver=new MutationObserver(ms=>{for(const m of ms){if(m.attributeName==='data-detail-mode')window.resourceModes.push(m.target.dataset.detailMode)}});window.resourceModeObserver.observe(document.querySelector('[data-slot=inventory-layout]'),{attributes:true})});return true;")
for width in [985,986,1000]:
self.inventory_width(width)
self.assertEqual(self.evaluate('window.resourceModes'),[])
self.assertEqual(self.evaluate('window.resourceObservers.active'),1)
self.code("await page.locator('[data-company-fixed-navigation] a').filter({hasText:'Resumen'}).click();return true;")
self.settle()
self.assertEqual(self.evaluate('window.resourceObservers.active'),0)
self.assertGreaterEqual(self.evaluate('window.resourceObservers.disconnected'),1)
self.code("await page.locator("+json.dumps('[data-company-fixed-navigation] a[href="'+self.company+'/resources"]')+").click();await page.getByRole('link',{name:'AUDIT-APP-01',exact:true}).waitFor();return true;")
self.settle()
self.assertEqual(self.evaluate('window.resourceObservers.active'),1)
self.record('resource-observer-cleanup',self.evaluate('window.resourceObservers'))
if __name__ == "__main__":
unittest.main()

View File

@ -110,6 +110,53 @@ class TestApi:
return False
def prepare_resources(api, company_id):
"""Fixtures documentales del catálogo real, repetibles dentro de la instalación desechable."""
catalog = api.request("/resource-catalog/" + company_id)["items"]
definitions = {item["builtinKey"]: item for item in catalog if item.get("builtinKey")}
existing = {item["values"]["name"]: item for item in api.request("/resources/" + company_id)["resources"]}
samples = [
("application", f"AUDIT-APP-{index:02d}", index % 4 != 0,
{"provider": "Proveedor sintético", "url": "https://example.test/aplicaciones",
"description": "Aplicación sintética para comparación y paginación."})
for index in range(1, 13)
] + [
("shared_folder", "AUDIT-FOLDER — Documentación compartida de soporte e infraestructura de la compañía", True,
{"unc": r"\\AUDIT-NAS\documentacion", "server": "AUDIT-NAS",
"description": "Carpeta documental sintética; sin publicación física ni permisos inventados."}),
("software_license", "AUDIT-LICENSE — Suite empresarial de pruebas", True,
{"software_product": "Suite sintética", "software_edition": "Enterprise de pruebas",
"provider": "Proveedor sintético", "license_model": "Suscripción",
"license_metric": "Por usuario", "license_quantity": 0, "auto_renew": False,
"reference": "AUDIT-LIC-01", "expires": "2027-12-31", "renews": "2027-12-01"}),
("certificate", "AUDIT-CERT — Certificado retirado", False,
{"holder": "Compañía sintética", "issuer": "Emisor de pruebas", "expires": "2025-01-01"}),
("windows_domain", "AUDIT-DOMAIN", True,
{"domain_dns": "audit.example.test", "domain_netbios": "AUDIT"}),
]
resources = []
for kind, name, active, values in samples:
template = definitions.get("template:" + kind)
if template is None:
raise RuntimeError("Falta una plantilla de serie para la fixture " + kind)
characteristics = {}
for key, value in values.items():
field = definitions.get("field:" + key)
if field is None:
raise RuntimeError("Falta un campo de serie para la fixture " + key)
characteristics[field["id"]] = value
resource = existing.get(name)
if resource is None:
resource = api.request("/resources/" + company_id, {"values": {
"name": name, "templateId": template["id"], "active": active,
"notes": "Documentación sintética para comprobar desplazamiento y continuidad del contexto. " * 30,
"characteristics": characteristics,
}}, expected=201)["resource"]
existing[name] = resource
resources.append({"id": resource["id"], "name": name, "kind": kind, "active": active})
return resources
def prepare_fixture(api):
if not api.request("/auth/session")["setupRequired"]:
raise RuntimeError("La instancia desechable no está vacía; se rechaza preparar datos.")
@ -132,9 +179,10 @@ def prepare_fixture(api):
"registeredOn": "2026-01-15", "status": "operational", "notes": "Activo sintético para auditorías visuales.",
"characteristics": {},
}}, expected=201)["asset"]
resources = prepare_resources(api, company_id)
api.request("/auth/logout", {})
return {"company": company_id, "asset": str(uuid.UUID(asset["id"])), "user": user["id"],
"email": "visual@audit.example.test", "password": password}
"email": "visual@audit.example.test", "password": password, "resources": resources}
def tool(client, name, arguments):
@ -296,7 +344,8 @@ def main():
wait_for(web.ready, frontend)
evidence = verify_browser(config, origin, fixture, directory, state, options.check)
report = {"origin": origin, "companyId": fixture["company"], "assetId": fixture["asset"],
"ordinaryUser": True, "unauthenticatedStatus": 401, "userManagementStatus": 403,
"ordinaryUser": True, "resources": fixture["resources"],
"unauthenticatedStatus": 401, "userManagementStatus": 403,
"reusedSession": True, "logoutChecked": options.check,
"databaseImage": DATABASE_IMAGE, "mcpImage": images[0], "screenshots": evidence}
(directory / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")

View File

@ -4,10 +4,12 @@
import json
import os
from pathlib import Path
import runpy
import subprocess
import tempfile
import tomllib
import unittest
import uuid
ROOT = Path(__file__).resolve().parent.parent
@ -83,5 +85,51 @@ class SessionLauncherTests(unittest.TestCase):
self.assertIn("ausente", result.stderr)
class ResourceFixtureTests(unittest.TestCase):
def setUp(self):
self.prepare = runpy.run_path(str(ROOT / "scripts/visual-audit.py"))["prepare_resources"]
keys = ["template:" + name for name in ["application", "shared_folder", "software_license", "certificate", "windows_domain"]]
keys += ["field:" + name for name in ["provider", "url", "description", "unc", "server", "software_product", "software_edition", "license_model", "license_metric", "license_quantity", "auto_renew", "reference", "expires", "renews", "holder", "issuer", "domain_dns", "domain_netbios"]]
self.catalog = [{"id": str(uuid.uuid4()), "builtinKey": key} for key in keys]
self.resources = []
self.posts = []
self.company = str(uuid.uuid4())
def request(self, path, body=None, expected=200):
if path == "/resource-catalog/" + self.company:
return {"items": self.catalog}
self.assertEqual(path, "/resources/" + self.company)
if body is None:
return {"resources": self.resources}
self.assertEqual(expected, 201)
resource = {"id": str(uuid.uuid4()), **body}
self.posts.append(body)
self.resources.append(resource)
return {"resource": resource}
def test_resources_are_repeatable_without_duplicate_writes(self):
first = self.prepare(self, self.company)
count = len(self.posts)
self.assertGreater(count, 10)
self.assertEqual(self.prepare(self, self.company), first)
self.assertEqual(len(self.posts), count)
self.assertEqual({row["kind"] for row in first}, {"application", "shared_folder", "software_license", "certificate", "windows_domain"})
def test_missing_catalog_fails_before_creating_resources(self):
self.catalog = [item for item in self.catalog if item["builtinKey"] != "field:provider"]
with self.assertRaisesRegex(RuntimeError, "Falta un campo"):
self.prepare(self, self.company)
self.assertEqual(self.posts, [])
def test_values_respect_limits_and_preserve_zero_and_false(self):
self.prepare(self, self.company)
fields = {item["builtinKey"]: item["id"] for item in self.catalog}
license_values = next(item["values"] for item in self.resources if item["values"]["name"].startswith("AUDIT-LICENSE"))
self.assertEqual(license_values["characteristics"][fields["field:license_quantity"]], 0)
self.assertIs(license_values["characteristics"][fields["field:auto_renew"]], False)
self.assertEqual({item["values"]["active"] for item in self.resources}, {True, False})
self.assertTrue(all(len(item["values"]["name"]) <= 100 and len(item["values"]["notes"]) <= 10000 for item in self.resources))
if __name__ == "__main__":
unittest.main()