From 91f1d07e975e25723fe28358f6fa7b6b2dde07cc Mon Sep 17 00:00:00 2001
From: David
Date: Sun, 25 Oct 2026 13:46:06 +0100
Subject: [PATCH] Add tests for current operating systems and workplace asset
management
- Implement `current-operating-systems-ui.test.py` to validate the functionality of current operating systems in the UI, including tests for source and states, computer changes, responsive design, error handling, and version saving.
- Create `workplace-add-asset.test.py` to test the addition of assets to workstations, covering scenarios such as conflict resolution, responsive design on mobile, error handling during asset creation, and HTTP guards for asset management.
---
.../global-applications.integration.test.ts | 116 +++++++
docs/aplicaciones-disponibles-por-compania.md | 66 ++++
...a-sistemas-operativos-activos-y-puestos.md | 4 +
...decision-so-actual-en-activos-y-puestos.md | 110 +++++++
docs/componentes-de-interfaz.md | 28 ++
docs/mapa-visual-del-puesto.md | 105 +++++++
docs/puestos-de-trabajo.md | 6 +
docs/software-del-ordenador.md | 8 +
modules/assets/package.json | 2 +-
.../workstation-concurrency.test.ts | 102 ++++++
.../api/application/workstation.use-cases.ts | 11 +-
.../infrastructure/http/workstation.routes.ts | 14 +-
modules/assets/src/common/locales/en.json | 17 +-
modules/assets/src/common/locales/es.json | 17 +-
.../assets/detail/types/asset-detail-props.ts | 1 +
.../assets/detail/ui/asset-detail-content.tsx | 3 +-
.../assets/detail/ui/asset-detail-page.tsx | 3 +
.../assets/detail/ui/asset-detail.test.tsx | 9 +
.../src/web/assets/detail/ui/asset-detail.tsx | 3 +-
.../use-asset-picker-controller.ts | 30 +-
.../controllers/use-asset-picker-data.ts | 32 ++
modules/assets/src/web/index.ts | 1 +
.../src/web/shared/api/inventory-request.ts | 8 +-
.../controllers/use-workstation-asset-add.ts | 157 ++++++++++
.../ui/workstation-asset-add-actions.tsx | 49 +++
.../ui/workstation-asset-add-dialog.tsx | 108 +++++++
.../rename/api/rename-workstation.api.ts | 7 +-
.../shared/api/save-workstation.api.ts | 13 +-
.../use-workstation-form-controller.ts | 52 ++--
.../workstation-asset-availability.test.ts | 70 +++++
.../helpers/workstation-asset-availability.ts | 35 +++
.../shared/ui/workstation-form.tsx | 9 +-
.../shared/ui/workstation-save-feedback.tsx | 30 ++
modules/companies/package.json | 2 +-
modules/companies/src/common/locales/en.json | 11 +
modules/companies/src/common/locales/es.json | 11 +
.../detail/ui/asset-operating-systems.tsx | 14 +
.../companies/detail/ui/company-inventory.tsx | 4 +
.../detail/ui/current-operating-systems.tsx | 90 ++++++
.../shared/current-operating-systems.test.tsx | 292 ++++++++++++++++++
.../helpers/current-operating-systems.ts | 74 +++++
.../shared/queries/use-asset-software.ts | 30 ++
.../shared/helpers/workspace-routes.ts | 7 +-
.../workspace/shared/workspace-routes.test.ts | 20 ++
.../controllers/use-workplace-map-model.ts | 18 +-
.../controllers/use-workstation-software.ts | 32 +-
.../helpers/workplace-map-layout.test.ts | 34 ++
.../detail/helpers/workplace-map-layout.ts | 51 ++-
.../detail/helpers/workplace-map-model.ts | 32 +-
.../detail/ui/workplace-add-asset-node.tsx | 34 ++
.../detail/ui/workplace-compact-map.tsx | 11 +-
.../detail/ui/workplace-map-canvas.tsx | 10 +-
.../detail/ui/workplace-map-presentation.tsx | 33 ++
.../detail/ui/workplace-visual-map-card.tsx | 54 +++-
.../detail/ui/workstation-detail-content.tsx | 11 +-
.../detail/ui/workstation-detail-page.tsx | 1 +
.../detail/ui/workstation-detail.test.tsx | 39 ++-
.../application/resource-reference-service.ts | 5 +
.../http/resource-application.routes.ts | 19 ++
.../persistence/resource-reference-reader.ts | 34 ++
.../resource-application.response.dto.ts | 10 +
.../dto/shared/application-reference.dto.ts | 7 +
modules/resources/src/common/locales/en.json | 6 +
modules/resources/src/common/locales/es.json | 6 +
.../shared/api/application-references-api.ts | 24 +-
.../queries/application-reference-keys.ts | 7 +-
.../queries/global-applications-keys.ts | 1 +
.../use-company-applications-controller.ts | 87 ++++++
.../use-resources-page-controller.ts | 21 ++
.../list/ui/company-applications-list.tsx | 199 ++++++++++++
.../list/ui/resource-inventory-table.tsx | 169 ++++++++++
.../web/resources/list/ui/resources-page.tsx | 173 ++---------
.../shared/api/resource-related-query-keys.ts | 3 +
.../shared/ui/resource-editor-dialog.tsx | 3 +
scripts/company-applications-ui-audit.py | 79 +++++
scripts/current-operating-systems-ui.test.py | 226 ++++++++++++++
scripts/global-applications-ui-audit.py | 2 +-
scripts/visual-audit.py | 6 +
scripts/workplace-add-asset.test.py | 158 ++++++++++
scripts/workplace-visual-map.test.py | 3 +-
80 files changed, 3093 insertions(+), 296 deletions(-)
create mode 100644 docs/aplicaciones-disponibles-por-compania.md
create mode 100644 docs/architecture/decision-so-actual-en-activos-y-puestos.md
create mode 100644 modules/assets/src/api/application/workstation-concurrency.test.ts
create mode 100644 modules/assets/src/web/assets/select/controllers/use-asset-picker-data.ts
create mode 100644 modules/assets/src/web/workstations/associate-assets/controllers/use-workstation-asset-add.ts
create mode 100644 modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-actions.tsx
create mode 100644 modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-dialog.tsx
create mode 100644 modules/assets/src/web/workstations/shared/helpers/workstation-asset-availability.test.ts
create mode 100644 modules/assets/src/web/workstations/shared/helpers/workstation-asset-availability.ts
create mode 100644 modules/assets/src/web/workstations/shared/ui/workstation-save-feedback.tsx
create mode 100644 modules/companies/src/web/assets/detail/ui/asset-operating-systems.tsx
create mode 100644 modules/companies/src/web/software-installations/detail/ui/current-operating-systems.tsx
create mode 100644 modules/companies/src/web/software-installations/shared/current-operating-systems.test.tsx
create mode 100644 modules/companies/src/web/software-installations/shared/helpers/current-operating-systems.ts
create mode 100644 modules/companies/src/web/software-installations/shared/queries/use-asset-software.ts
create mode 100644 modules/companies/src/web/workstations/detail/ui/workplace-add-asset-node.tsx
create mode 100644 modules/companies/src/web/workstations/detail/ui/workplace-map-presentation.tsx
create mode 100644 modules/resources/src/web/resources/list/controllers/use-company-applications-controller.ts
create mode 100644 modules/resources/src/web/resources/list/ui/company-applications-list.tsx
create mode 100644 modules/resources/src/web/resources/list/ui/resource-inventory-table.tsx
create mode 100644 scripts/company-applications-ui-audit.py
create mode 100644 scripts/current-operating-systems-ui.test.py
create mode 100644 scripts/workplace-add-asset.test.py
diff --git a/apps/server/src/global-applications.integration.test.ts b/apps/server/src/global-applications.integration.test.ts
index 4e9c5908..b051d59c 100644
--- a/apps/server/src/global-applications.integration.test.ts
+++ b/apps/server/src/global-applications.integration.test.ts
@@ -9,9 +9,11 @@ import {
GlobalApplicationCapabilitiesSchema,
GlobalApplicationTemplateResponseSchema,
InventoryResponseSchema,
+ ListCompanyApplicationsResponseSchema,
ListGlobalApplicationHistoryResponseSchema,
ListGlobalApplicationsResponseSchema,
ResolveApplicationReferencesResponseSchema,
+ ResourceResponseSchema,
} from "@metis/resources/common";
import { createResourceModule, migrateResourceScope } from "@metis/resources/module";
import { QueryTypes } from "sequelize";
@@ -173,6 +175,120 @@ test("R2.3: catálogo global autorizado, historia, idempotencia, privacidad y co
let product = GetGlobalApplicationAdminResponseSchema.parse(await created.json()).resource;
const originalRevisionId = product.revisionId;
+ await t.test(
+ "catálogo de compañía combina ámbitos, pagina y permite altas locales aisladas",
+ async () => {
+ const path = `/resource-application-candidates/${companyId}/catalog`;
+ const firstResponse = await ordinary.request(`${path}?pageSize=20`);
+
+ assert.equal(firstResponse.status, 200);
+ const first = ListCompanyApplicationsResponseSchema.parse(await firstResponse.json());
+ const ids = first.items.map((item) => item.id);
+ let cursor = first.nextCursor;
+
+ while (cursor) {
+ const response = await ordinary.request(
+ `${path}?pageSize=20&cursor=${encodeURIComponent(cursor)}`
+ );
+
+ assert.equal(response.status, 200);
+ const page = ListCompanyApplicationsResponseSchema.parse(await response.json());
+
+ ids.push(...page.items.map((item) => item.id));
+ cursor = page.nextCursor;
+ }
+ assert.equal(ids.length, 51);
+ assert.equal(new Set(ids).size, 51);
+ assert.ok(ids.includes(creationId));
+ assert.ok(locals.every((id) => ids.includes(id)));
+ const foreign = ListCompanyApplicationsResponseSchema.parse(
+ await (
+ await ordinary.request(`/resource-application-candidates/${other}/catalog?pageSize=100`)
+ ).json()
+ );
+
+ assert.equal(foreign.items.length, 2);
+ assert.ok(foreign.items.some((item) => item.id === creationId && item.scope === "global"));
+ assert.ok(
+ foreign.items.every((item) => item.scope === "global" || item.companyId === other)
+ );
+ assert.equal((await anonymous.request(path)).status, 401);
+ assert.equal(
+ (
+ await ordinary.request(
+ `/resource-application-candidates/${UniqueID.generateNewID().toPrimitive()}/catalog`
+ )
+ ).status,
+ 404
+ );
+ assert.equal((await ordinary.request(`${path}?unknown=value`)).status, 400);
+ assert.equal(
+ (
+ await ordinary.request(
+ `/resource-application-candidates/${other}/catalog?pageSize=20&cursor=${encodeURIComponent(first.nextCursor ?? "invalid")}`
+ )
+ ).status,
+ 400
+ );
+ for (const item of first.items) {
+ assert.deepEqual(
+ Object.keys(item).sort(),
+ ["id", "templateId", "name", "active", "kind", "scope", "companyId", "provider"].sort()
+ );
+ }
+ const createdLocal = await ordinary.request(`/resources/${companyId}`, {
+ values: {
+ name: "Aplicación exclusiva Ñ",
+ templateId: template.templateId,
+ active: true,
+ notes: "Privada",
+ characteristics: {},
+ },
+ });
+
+ assert.equal(createdLocal.status, 201);
+ const local = ResourceResponseSchema.parse(await createdLocal.json()).resource;
+ const found = ListCompanyApplicationsResponseSchema.parse(
+ await (await ordinary.request(`${path}?search=exclusiva`)).json()
+ );
+
+ assert.equal(found.items.length, 1);
+ assert.equal(found.items[0]?.scope, "company");
+ assert.equal(found.items[0]?.companyId, companyId);
+ const absent = ListCompanyApplicationsResponseSchema.parse(
+ await (
+ await ordinary.request(
+ `/resource-application-candidates/${other}/catalog?search=exclusiva`
+ )
+ ).json()
+ );
+
+ assert.equal(absent.items.length, 0);
+ assert.equal(
+ (await ordinary.request(`/resources/${companyId}/${local.id}/status`, { active: false }))
+ .status,
+ 200
+ );
+ const inactive = ListCompanyApplicationsResponseSchema.parse(
+ await (await ordinary.request(`${path}?active=false`)).json()
+ );
+
+ assert.deepEqual(
+ inactive.items.map((item) => item.id),
+ [local.id]
+ );
+ const active = ListCompanyApplicationsResponseSchema.parse(
+ await (await ordinary.request(`${path}?active=true&search=exclusiva`)).json()
+ );
+
+ assert.equal(active.items.length, 0);
+ assert.equal(
+ (await ordinary.request(`/resources/${companyId}/${local.id}/delete`, {})).status,
+ 200
+ );
+ }
+ );
+
await t.test(
"scope y plantilla persistidos, autorización real y proyección mínima",
async () => {
diff --git a/docs/aplicaciones-disponibles-por-compania.md b/docs/aplicaciones-disponibles-por-compania.md
new file mode 100644
index 00000000..9013154a
--- /dev/null
+++ b/docs/aplicaciones-disponibles-por-compania.md
@@ -0,0 +1,66 @@
+# Aplicaciones disponibles por compañía
+
+En Recursos, la pestaña **Aplicación** combina el catálogo global con las aplicaciones
+particulares de la compañía. El origen distingue productos homónimos, sin fusionarlos
+ni copiar los globales. El resto de pestañas y el inventario particular mantienen sus
+contratos de compañía.
+
+El botón **Añadir aplicación de compañía** abre el formulario existente con la plantilla
+de aplicación preseleccionada. El servidor guarda el recurso únicamente en la compañía
+de contexto. Las globales abren su ficha de catálogo; las particulares conservan sus
+acciones de consulta, edición, activación, desactivación y borrado.
+
+`GET /api/resource-application-candidates/:companyId/catalog` exige sesión y valida la
+existencia de la compañía antes de consultar datos. Devuelve resúmenes públicos de
+aplicaciones globales y propias, sin notas, historial ni datos de otras compañías.
+Utiliza los criterios de búsqueda, estado, orden y paginación del catálogo global,
+con cursor ligado a compañía y filtros. Las plantillas propias con uso `application`
+también se incluyen; se excluyen sistemas operativos y licencias.
+
+La UI utiliza `CursorEntityTable`, mantiene los criterios en la URL y reinicia la página
+al cambiar búsqueda, orden, estado o tamaño. El proveedor ausente se representa con `—`.
+Los cambios locales invalidan el catálogo de esa compañía y sus candidatos; los cambios
+globales invalidan los catálogos combinados. No se guarda una segunda copia del estado remoto.
+
+Los selectores existentes `ResourceApplicationSelect` y `SoftwareApplicationSelect`
+consultan candidatos activos de ambos ámbitos, conservando sus reglas por propósito:
+configuraciones de perfil solo aceptan aplicaciones; instalaciones admiten además SO locales.
+Para asociar globales deben estar habilitados `RESOURCE_GLOBAL_CATALOG_ENABLED`,
+`RESOURCE_GLOBAL_SOFTWARE_ENABLED`, `RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED` y
+`RESOURCE_GLOBAL_PROFILE_ENABLED`, tras migrar el esquema. Consultar no equivale a habilitar
+asociaciones y las lecturas históricas permanecen disponibles con los gates cerrados.
+
+La integración se comprueba con `python3 scripts/resource-scope-test.py --catalog`,
+`--software` y `--profiles`. El recorrido de interfaz se reproduce mediante
+`python3 scripts/visual-audit.py --company-applications --check`: utiliza exclusivamente
+una instalación desechable `metis_desk_test`, con 15 compañías, al menos 50 personas,
+activos, perfiles, puestos y aplicaciones propias en la principal, y 60 aplicaciones globales.
+No utiliza datos reales ni credenciales del entorno habitual.
+
+Skills consultadas: `ux-tables` para procedencia, acciones y paginación; `shadcn` para
+integrar los componentes instalados; `rdx-solid` para contratos, aislamiento y pruebas;
+`vercel-react-best-practices` para derivar datos y mantener el estado remoto en TanStack Query.
+Los auditores especializados no están instalados; las verificaciones disponibles se
+realizan mediante Playwright MCP, sin instalar dependencias adicionales.
+
+## Verificación de la entrega
+
+- Typecheck dirigido de Resources, Server y Companies: correcto.
+- Biome y ESLint sobre los archivos del cambio, sintaxis Python y `git diff --check`: correctos.
+- Pruebas de Resources: 105 correctas. Prueba de rutas: correcta.
+- Integraciones desechables: catálogo 12, software 13 y perfiles 11 comprobaciones correctas.
+- Playwright MCP: alta local desde el formulario, aislamiento entre compañías, búsqueda,
+ paginación, vacío, error/reintento, selectores de software y perfil, Escape y retorno del foco.
+ Cuatro anchos: 1440, 1280, 768 y 390 px; capturas amplias y estrechas inspeccionadas.
+ Informe reproducible: `.artifacts/playwright/authenticated-dhutc3tz/company-applications-report.json`.
+- `pnpm build`: correcto; se conserva el aviso de tamaño de chunks.
+- `pnpm typecheck` global: bloqueado por el tipo opcional `locale` de
+ `packages/shadcn-ui/src/components/calendar.tsx`, fuera del alcance y vendor.
+- `pnpm test` completo, sobre una base desechable: falla en las aserciones de textos
+ de compañía/persona de `companies-page.test.tsx`, ajenas a los recursos de aplicaciones.
+- `pnpm lint` y `pnpm check` globales: fallos de estilo en código concurrente de fotos
+ de personas y procesos de Companies. No se han modificado esos archivos para esta tarea.
+
+En la instalación habitual se han habilitado los gates existentes y reiniciado el entorno.
+La comprobación final de solo lectura en Abeto Design devuelve las 40 aplicaciones
+globales y mantiene sus 107 buzones. No se han creado aplicaciones de prueba en la base habitual.
diff --git a/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md b/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md
index 21abac1d..1d6dc9ad 100644
--- a/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md
+++ b/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md
@@ -331,3 +331,7 @@ No se ejecutaron `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm check` ni `pn
Volumen realmente utilizado: fixtures pequeños en memoria definidos por las pruebas, sin carga de compañías/activos/personas/recursos en BD. El escenario aislado de 15 compañías y al menos 50 personas, activos y recursos de una principal queda pendiente para una futura validación funcional/visual; no se declara ejecutado aquí. Tampoco se midieron rendimiento, accesibilidad o navegación real.
Entrega autorizada: un informe Markdown. **No se implementó ninguna recomendación, modelo, endpoint, migración o cambio de componente.**
+
+## 17. Decisión posterior a la auditoría
+
+El 25 de octubre de 2026 el usuario aprobó implementar el resumen derivado del SO actual en activos y puestos, reutilizando SoftwareInstallation y Resources. El alcance, implementación y validaciones posteriores se documentan en [Decisión aprobada — SO actual en activos y puestos](decision-so-actual-en-activos-y-puestos.md). Esta sección no modifica retrospectivamente los hallazgos ni las validaciones originales del informe.
diff --git a/docs/architecture/decision-so-actual-en-activos-y-puestos.md b/docs/architecture/decision-so-actual-en-activos-y-puestos.md
new file mode 100644
index 00000000..010a21bd
--- /dev/null
+++ b/docs/architecture/decision-so-actual-en-activos-y-puestos.md
@@ -0,0 +1,110 @@
+# Decisión aprobada — SO actual en activos y puestos
+
+Fecha: 25 de octubre de 2026. Decisión funcional aprobada por el usuario para esta intervención. Complementa la [auditoría arquitectónica](auditoria-sistemas-operativos-activos-y-puestos.md), cuyos hallazgos originales se conservan.
+
+## Decisión y alcance
+
+El SO de fábrica permanece en las características de Assets. El producto SO sigue siendo un recurso particular `os` de Resources; su instalación y revisiones pertenecen a `SoftwareInstallation` de Companies. La ficha de activo y la del puesto muestran una proyección compartida de esas instalaciones. No se añaden persistencia, entidades, migraciones, estados, derechos de licencia, requisitos de perfil ni catálogo global de SO.
+
+La selección exige simultáneamente compañía y activo correctos, estado `configured` y referencia pública resuelta de tipo `os`. Se excluyen `pending`, `removal_pending`, `removed` y `never_configured`. Un producto inactivo que el contrato permite resolver conserva su información y el aviso de revisión del registro; no se relajan permisos ni pertenencia.
+
+Se admiten todos los productos configurados diferentes que permite la unicidad existente por pareja activo/recurso. El orden es por nombre con locale explícito `en` y UUID como desempate. Se muestra la versión de instalación salvo cuando el texto completo ya figura en el nombre, delimitado por espacios o signos y comparado sin diferencias de mayúsculas, espacios o representación Unicode. No se confunde `1` con `11` ni `24.04` con `24X04`. La comparación afecta solo a la presentación: no cambia nombres, ediciones ni versiones guardadas y no incorpora reglas por fabricante.
+
+## Presentación
+
+- Activo: tarjeta **Sistema operativo actual** después de sus características técnicas, antes de notas e información adicional. Los campos originales de identificación y características conservan su orden. Companies aporta la tarjeta al slot `technicalSummary` de la API web pública de Assets, que no importa Companies.
+- Puesto: la misma tarjeta antes del bloque de gestión de software, calculada a partir del principal validado `computerAssetId`. Cambiarlo selecciona otras instalaciones sin mover registros, licencias o historial.
+- Varios productos: título plural y lista completa con enlaces; una o dos columnas según capacidad del contenedor. No se reduce a una etiqueta que oculte productos.
+- Sin SO configurado: **No registrado / Not recorded**. No significa ausencia física de SO. Los campos `factory_os` y `factory_preloaded_os` no participan en esta lectura.
+- Carga inicial o resolución necesaria pendiente: skeleton con estado accesible. Error de instalaciones o de resolución necesaria: mensaje con Reintentar, incluso si hay caché previa; no se presenta como vacío ni como dato vigente confirmado.
+- Referencia configurada no resoluble o sin nombre utilizable: aviso de referencia de software no disponible, conservando los SO que sí se resuelven. Sin metadatos no se puede saber si la referencia desconocida era SO o aplicación: se evita afirmar «No registrado» en ese caso.
+
+El criterio es independiente de la plantilla del activo. Funciona en portátil, sobremesa, servidor sin puesto y plantilla propia; cualquier otro activo puede mostrar un SO si existe una instalación explícita `os`. No se infiere SO a partir del firmware, el nombre o la categoría. Para activos sin registros se aplica el mismo estado «No registrado», sin una nueva taxonomía de elegibilidad.
+
+La gestión conserva el panel y formulario de instalaciones de la ficha del activo y el diálogo existente del puesto, incluyendo estados, versión, licencia e historial. No se incorpora otro formulario. El mapa sigue mostrando software abierto con su estado, incluidas configuraciones pendientes: **no es una lista de SO actuales**. Comparte la selección de ámbito/activo y conserva una tarjeta por instalación; la lista completa de SO actuales permanece en el resumen.
+
+## Código y fronteras
+
+| Área | Archivos y responsabilidad |
+| --- | --- |
+| Selección pura | `modules/companies/src/web/software-installations/shared/helpers/current-operating-systems.ts`: ámbito, activo, estados, tipo, referencia no disponible, orden y versión visible. |
+| Lectura compartida | `software-installations/shared/queries/use-asset-software.ts`: `softwareKeys.company`, `softwareApi.get` y `useSoftwareReferences` público de Resources. No copia estado remoto ni usa efectos de sincronización. |
+| Presentación | `software-installations/detail/ui/current-operating-systems.tsx`: tarjeta común con DetailPanel, Skeleton, Button y BrowserLink ya instalados; traducciones es/en y tokens semánticos. |
+| Integración de activo | `assets/detail/ui/asset-operating-systems.tsx` y `companies/detail/ui/company-inventory.tsx` en Companies; slot ReactNode de `AssetDetailPage`/`AssetDetailContent`/`AssetDetail` en Assets. |
+| Integración de puesto | `use-workstation-software.ts`, `workstation-detail-content.tsx` y `use-workplace-map-model.ts`; el mapa resuelve los nombres con las mismas referencias públicas y reutiliza `getAssetSoftwareInstallations` en `workplace-map-model.ts`. |
+| Pruebas | `current-operating-systems.test.tsx`, regresiones de ficha de activo/puesto y `scripts/current-operating-systems-ui.test.py`. La suite nueva se añade al comando de Companies. |
+
+Las rutas de servidor, DTO HTTP, dominio y repositorios permanecen sin cambios de esta intervención. Se consume el resolver por propósito `software-installation`, que admite SO particulares y aplicaciones según sus garantías vigentes. El árbol ya contenía trabajo concurrente en Resources y continuó cambiando durante la tarea; esos cambios no se atribuyen a esta decisión ni se revierten.
+
+La caché se comparte con la gestión existente. Guardar una instalación invalida la query de compañía y las referencias por propósito mediante el formulario existente; ambos resúmenes se recalculan durante render. El cambio de principal filtra inmediatamente por el nuevo UUID y resuelve sus productos. Los identificadores de instalación, licencia y revisiones no cambian por ese cambio de contexto.
+
+## Validación técnica
+
+| Comprobación ejecutada | Resultado |
+| --- | --- |
+| Selección dirigida con `node --import tsx --test --test-isolation=none` | 75/75: nueva suite SO, panel/formulario software, ficha de puesto, mapa/layout, ficha de activo y dominio SoftwareInstallation. |
+| `pnpm --filter @metis/assets test` | 27 archivos correctos; el runner agrupa por archivo. |
+| `pnpm --filter @metis/companies test` | 49/50 archivos correctos. El archivo `companies-page.test.tsx` falla en expectativas de contador de personas y ayuda de formulario de persona, fuera de este cambio. Su ejecución individual confirma esas dos expectativas; no se modifican para ocultarlas. |
+| `python3 scripts/resource-scope-test.py --software --regressions` | 13/13 de software HTTP/MariaDB y 50/50 regresiones; contenedor propio `metis_desk_test` retirado. |
+| Typechecks dirigidos de Assets, Companies y Web | Correctos. |
+| Biome y ESLint dirigidos a archivos propios | Correctos. |
+| `pnpm build` | Correcto; conserva aviso de bundle web mayor de 500 kB. |
+| `pnpm typecheck` global | Falla en vendor `packages/shadcn-ui/src/components/calendar.tsx:165`, TS2375 por `locale`; vendor sin cambios propios. |
+| `pnpm lint` y `pnpm check` globales | Mantienen diagnósticos fuera de los archivos propios, incluidas convenciones/skills y código previo/concurrente. No se declara el árbol global validado. |
+
+No se ejecuta un `pnpm test` global que pudiera consumir configuración de BD habitual. La integración SQL usa el runner desechable; las suites web/dominio dirigidas no usan BD. Los resultados históricos de la auditoría anterior no se reutilizan como pruebas nuevas.
+
+## Evidencia interactiva y reproducción
+
+Entorno preparado con `bash scripts/setup-playwright-wsl.sh` y `python3 scripts/visual-audit.py`, siguiendo [Playwright MCP](../playwright-mcp.md). Se usa el servidor MCP Docker fijado en `.codex/config.toml` y el cliente stdio existente; no se instala Chromium ni se crea configuración global. Origen Vite temporal verificado: `http://localhost:36037`. Se inicia sesión por el formulario real como operador ordinario y solo se persiste sobre la BD desechable `metis_desk_test`.
+
+La preparación reproducible utiliza `visual-audit-volume.py`, `workstation-dashboard-fixtures.py` y después `current-operating-systems-ui.test.py`, pasando `METIS_PLAYWRIGHT_STORAGE_STATE` únicamente como indica el procedimiento. Esta última comprueba ruta privada y origen del preparador, conserva fixtures solo dentro de ese entorno y no permite elegir una BD existente. Las credenciales/cookies no se incluyen en documentación, capturas ni Git.
+
+Volumen preparado: 15 compañías, 59 personas, al menos 143 activos y 76 recursos en la principal; puesto de densidad con 80 miembros y 60 perfiles. Los casos adicionales de SO incluyen portátil con FreeDOS original, sobremesa Windows, servidor sin puesto, plantilla de activo propia, activo sin SO de fábrica, fábrica sin instalación actual, pendiente, retirada, dos SO, aplicación convencional y un segundo ordenador.
+
+La regresión MCP del mapa (`scripts/workplace-visual-map.test.py`) pasa **7 recorridos**, incluyendo seis anchos, densidad, navegación/gestión, errores, principal, refresco, contraste y movimiento reducido. Sus evidencias se conservan bajo el directorio de la ejecución desechable; no se confunde su software abierto con el resumen nuevo. Tras compartir también los nombres del resolver, se repitieron estados parciales y refresco; el último caso pasa con la fixture coherente actualizada.
+
+El recorrido nuevo completo pasa **7/7 pruebas MCP** (`ui-final.log`), con las siguientes evidencias:
+
+| Comprobación | Evidencia observada |
+| --- | --- |
+| OSUI-01–07, 12–14, 17–18 | Origen FreeDOS/Windows independiente, configurado frente a pendiente/retirado, dos SO sin aplicaciones, servidor sin puesto y plantilla propia, leídos por API real y comprobados en la ficha. |
+| OSUI-08 | Sustitución del principal: el resumen pasa al otro SO y las instalaciones/licencias/revisiones antes y después son idénticas por HTTP. |
+| OSUI-09/15 | Guardado en el formulario existente: aparece `Versión 25H2` en el resumen sin recargar. |
+| OSUI-10/11 | Respuesta de referencia `unavailable` y HTTP 500 interceptados por MCP: aviso/error con reintento, sin «No registrado»; al retirar la interceptación se recupera. |
+| Responsive y temas | Activo y puesto: 320, 390, 768, 1024, 1280 y 1600 px, en claro y oscuro; 24 combinaciones con dos productos y nombres largos, sin overflow de la tarjeta. |
+| Contraste local | Mínimo medido de texto/enlaces: 4,648:1 en claro y 4,749:1 en oscuro, sobre fondos efectivos de la tarjeta. No es una certificación de toda la aplicación. |
+| Teclado/foco | Los enlaces del resumen reciben foco y Tab recorre ambos, con outline visible. |
+| Carga | Skeleton con nombre accesible, capturado durante el reintento inicial de un HTTP 503 sintético; después se retira la interceptación y se resuelve la lectura real. |
+| OSUI-16 | Selector real de idioma: tarjeta traducida a inglés; además, render es/en en las pruebas dirigidas. |
+| Zoom | Reflow con CSS `zoom: 2` y sin overflow de la tarjeta. No se ha controlado ni se declara probado el zoom nativo de la interfaz del navegador. |
+
+Directorio local excluido de Git: `.artifacts/playwright/authenticated-2jopd8ii/current-operating-systems/`. `manifest.json` registra dimensiones y SHA-256 de las capturas; `measurements.json` y `ui-final.log` conservan los resultados. Muestras abiertas e inspeccionadas: `asset-light-320.png`, `asset-dark-1600.png`, `workstation-dark-320.png`, `saved-version.png`, `workstation-english.png`, `workstation-loading.png` y `asset-error.png`. Las rutas no contienen sesión: el archivo privado de autenticación vive separado y se elimina al cerrar el preparador.
+
+La regresión del mapa se amplía para que los nombres sintéticos largos coincidan tanto en inventario como en el resolver público; no se simulan dos nombres contradictorios del mismo producto. Las primeras iteraciones del procedimiento de carga fallaron por manejo de rutas MCP y una etiqueta incorrecta del selector de idioma; se corrigió el procedimiento y el recorrido completo final pasa. No se alteró código de producción para ocultar esos fallos de prueba.
+
+Las capturas nuevas corresponden a la tarjeta de SO dentro del viewport indicado, no a una auditoría de toda la página. Se abren muestras representativas para revisión visual. No se certifica accesibilidad global, comportamiento de lectores de pantalla ni cumplimiento WCAG completo. Las limitaciones generales de las fichas ajenas al resumen —por ejemplo acciones extensas de otros paneles en móvil— no se corrigen en esta intervención.
+
+## Skills consultadas
+
+Principal: `vercel-composition-patterns`, según la matriz del proyecto, para componente compartido y composición desde Companies mediante un slot de Assets. `vercel-react-best-practices` orienta cálculo derivado durante render y reutilización de la caché TanStack existente, sin sustituirla por SWR. `shadcn` se aplica consultando las APIs locales reales de DetailPanel, Skeleton y Button; el intento `pnpm dlx shadcn@latest docs skeleton button` falla por DNS `EAI_AGAIN`, por lo que no se atribuye lectura de documentación remota. No se modifica vendor ni se instala ninguna dependencia del proyecto.
+
+`rdx-solid` mantiene la solución dentro de los agregados y contratos actuales. La consulta puntual de `ui-ux-pro-max` sobre textos largos respalda `min-w-0` y wrapping de tokens, subordinados a DESIGN. El contraste, foco y responsive se contrastan mediante navegador, no se deducen de las skills. No falta ninguna skill necesaria para la implementación; no se instalan auditores adicionales.
+
+## Limitaciones y siguientes decisiones
+
+Se conserva la consulta completa de instalaciones de compañía y el límite de una pareja abierta por activo/producto. No se añaden instancias de arranque, procedencia/verificación persistida, ausencia confirmada, reconciliación con perfiles ni capacidad de licencias. La duplicidad de fábrica del servidor queda pendiente para otra intervención.
+
+La clasificación de una referencia inaccesible es desconocida; el aviso es deliberadamente de software y no inventa un SO. Las ediciones permanecen en el recurso y no se reconstruyen versiones comerciales desde nombres. La auditoría de historia inmutable, snapshots de producto/licencia y mejora de consultas por volumen también permanecen fuera del alcance aprobado.
+
+## Cierre del entorno
+
+El preparador se cerró con SIGINT tras identificar exclusivamente su proceso por los archivos de esta ejecución. Confirmó la retirada de servicios, contenedor/base y sesión temporales. Las 30 capturas y los logs de evidencia permanecen sin credenciales. El código de salida del preparador fue cero; no se detuvo ni modificó la base habitual.
+
+## Revalidación posterior del árbol de trabajo
+
+El 25 de octubre de 2026 se revisó de nuevo la integración conservada en el árbol de trabajo. No se volvió a implementar el resumen ni se modificó código de producción. La selección de compañía/activo, `configured` y recurso `os`, los estados de interfaz y la composición de activo/puesto siguen presentes.
+
+Se ejecutaron con `node --import tsx --test --test-isolation=none` las suites `current-operating-systems.test.tsx`, `software.test.tsx`, `asset-detail.test.tsx` y `software-installation.test.ts`: **19/19 casos correctos**. Se verificaron también los hashes de las 30 capturas conservadas y la ausencia del archivo privado de sesión; no se repitió el recorrido de navegador ni la integración SQL en esta revalidación.
+
+`pnpm --filter @metis/companies typecheck` registra ahora un fallo en el trabajo concurrente de asociación de activos: `modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-dialog.tsx:90`, TS2322. La llamada a `ContextHelp` aporta `children`, pero su contrato requiere `label`, `title` y `description`. Ese archivo no pertenece a la implementación del resumen y no se modifica en esta revisión. El resultado anterior de los typechecks dirigidos corresponde a su ejecución previa; no acredita que el árbol concurrente actual esté libre de errores.
diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md
index 935e6ec0..475e15ee 100644
--- a/docs/componentes-de-interfaz.md
+++ b/docs/componentes-de-interfaz.md
@@ -1491,6 +1491,15 @@ vendor ni el significado de los estados de negocio. Incluye las celdas de accion
### Selección de aplicaciones por propósito — R2.5
+La pestaña **Aplicación** de Recursos de compañía utiliza `CompanyApplicationsList` y
+`CursorEntityTable` para consultar las aplicaciones globales y las propias, sin copiarlas
+al inventario particular. Presenta origen, proveedor y estado, con búsqueda y filtros
+remotos. Las globales enlazan al catálogo; las particulares conservan edición, estado y
+borrado en su compañía. El alta contextual reutiliza `ResourceEditorDialog` con
+`initialKind="application"`. Las invalidaciones de recursos locales y del catálogo global
+actualizan tanto este listado como los candidatos de los selectores. Véase
+[aplicaciones disponibles por compañía](aplicaciones-disponibles-por-compania.md).
+
`ResourceApplicationSelect` y su picker privado comparten búsqueda, cursores,
procedencia y selección provisional entre instalaciones y configuraciones de
perfil. El consumidor aporta `purpose`; Resources filtra los candidatos. El
@@ -1599,3 +1608,22 @@ compartida, composición del badge y contratos con semántica de cada pantalla.
#### Refinamiento del mapa — Iteración 2
El mapa diferencia dimensiones de principal, secundarios y software. `WorkplaceSoftwareHeadingNode` rotula el grupo sin representar un recurso ni recibir foco; `WorkplaceMapCoverage` indica elementos fuera del preview solo con una fuente conocida y conduce a las colecciones existentes. La leyenda se limita a Principal, Asociado al puesto y Software del principal. La altura se deriva de los bounds de dagre; un refetch sin cambio de geometría conserva el canvas. Contratos y evidencia comparativa: [refinamiento visual del mapa](mapa-visual-del-puesto.md#refinamiento-visual--iteración-2).
+
+## Resumen de SO actual en activo y puesto
+
+`CurrentOperatingSystems`, en Companies (`software-installations/detail/ui`), compone DetailPanel, BrowserLink, Skeleton y Button existentes para mostrar el resultado de `useAssetSoftware` y `getCurrentOperatingSystems`. Maneja cero, uno o varios productos, versiones, referencias no disponibles y error recuperable. El componente es específico del software; no se incorpora lógica de dominio a rdx-ui ni a vendor.
+
+Assets expone el slot presentacional `technicalSummary` de su ficha después de las características técnicas. Companies aporta `AssetOperatingSystems` desde la composición de `CompanyInventory`, manteniendo Assets independiente de consultas/DTO de instalaciones. El puesto consume el mismo componente con su lectura del principal. Se conservan formularios, transiciones y caché existentes. Contrato funcional y evidencias: [SO actual](architecture/decision-so-actual-en-activos-y-puestos.md).
+
+### Asociación de activos desde el mapa — WD04.4
+
+`WorkplaceAddAssetNode` compone un botón neutro discontinuo, compartido por React Flow y
+`WorkplaceCompactMap`. Su identidad `presentation:add-asset` participa únicamente en
+Dagre: no tiene aristas de negocio ni modifica contadores. `WorkplaceMapPresentation`
+selecciona canvas o lectura compacta por el ancho efectivo.
+
+Assets publica `WorkstationAssetAddDialog`, con `AssetPickerList`, el asistente real
+`AssetEditorDialog` y confirmación separada de la asociación. `WorkstationSaveFeedback`
+unifica la recuperación de revisión obsoleta con el editor tradicional. El módulo
+consumidor aporta autorización, compañía, puesto y lookups; los nodos solo reciben
+callbacks. Contratos, pruebas y limitaciones: [WD04.4](mapa-visual-del-puesto.md#wd044--asociación-de-activos-desde-el-mapa-visual).
diff --git a/docs/mapa-visual-del-puesto.md b/docs/mapa-visual-del-puesto.md
index c1c66895..3e06aab0 100644
--- a/docs/mapa-visual-del-puesto.md
+++ b/docs/mapa-visual-del-puesto.md
@@ -237,3 +237,108 @@ La primera banda prioriza la misma jerarquía del mockup, pero mantiene los toke
La leyenda se sitúa debajo para no competir con el nodo principal ni reducir el ancho útil. El reparto bilateral depende de capacidad; las ventanas intermedias conservan una columna de asociados a la izquierda. Se mantienen los límites 2/3 y el resto se consulta en sus colecciones.
Quedan fuera de la validación lectores de pantalla concretos, ampliación exclusiva de texto al 200 %, alto contraste del sistema y modo oscuro. La medición de textos en claro y las pruebas de teclado no equivalen a certificar WCAG. Una mejora futura puede medir alturas a partir del contenido ampliado y ampliar esa matriz de accesibilidad; esta entrega no añade un motor de layout, virtualización, persistencia o edición de diagramas.
+
+## WD04.4 — Asociación de activos desde el mapa visual
+
+### Objetivo y equivalencia funcional
+
+El mapa ofrece «Añadir activo» como otra entrada a la edición de la composición de Workstation. Utiliza `saveWorkstation`, el mismo `POST /api/assets/:companyId/workstations/:id/update`, el mapper de referencias y la transacción de inventario existentes. Conserva nombre, estado, sede, responsable, miembros anteriores y ordenador principal. La ausencia de principal se conserva como la ausencia opcional que devuelve actualmente el contrato; añadir un ordenador no lo convierte en principal.
+
+No se añaden entidades, tablas, migraciones, endpoints del diagrama ni dependencias. Las asociaciones, restricciones e historial siguen perteneciendo a Assets. No se modifican las auditorías históricas ni Resources Global Scope.
+
+### Componentes y composición
+
+| Unidad | Responsabilidad |
+| --- | --- |
+| `WorkplaceAddAssetNode` | Botón semántico con Plus, borde discontinuo y tokens neutros. Nombre accesible «Añadir activo al puesto» y texto secundario «Seleccionar o crear». Reutilizado en canvas y presentación compacta. |
+| `WorkplaceMapPresentation` | Selecciona React Flow o `WorkplaceCompactMap` según el ancho real; mantiene la carga diferida del canvas. |
+| `workplaceMapLayout` | Incluye `presentation:add-asset` en Dagre junto al equipamiento. Los enlaces internos de distribución no se devuelven como aristas. El software queda debajo de todo el conjunto de equipamiento. |
+| `WorkstationAssetAddDialog` | Diálogo Picker List de Assets, exportado mediante su API pública. Companies aporta puesto, compañía, lookups y destino del foco. |
+| `useWorkstationAssetAdd` | Selección, snapshot de revisión, alta relacionada, guardado y recuperación de lectura. Los nodos no conocen HTTP ni consultas. |
+| `WorkstationAssetAddActions` | Pie de creación, cancelación y confirmación; bloquea acciones durante las operaciones pendientes. |
+| `workstationAssetUnavailable` | Regla de disponibilidad extraída del editor existente y compartida con el nuevo flujo. |
+| `WorkstationSaveFeedback` | Error y recuperación explícita de conflictos, compartidos por el picker y el editor tradicional. |
+
+El nodo de acción no tiene identidad de Asset, no entra en el adaptador de activos, en `assetIds`, en los contadores ni en el historial. No tiene estados técnicos ni enlace de ficha. El límite visual continúa siendo principal + dos asociados y tres elementos de software; la acción se añade aparte. «Ver todo el equipamiento» conserva acceso a los miembros ocultos.
+
+Dagre conserva un layout determinista, sin coordenadas persistidas. La distribución admite dos lados desde 720 px efectivos, evitando alargar innecesariamente el mapa al incorporar la acción. El canvas conserva la política existente de cámara estable: se recalcula cuando cambian dimensiones o composición visible, sin ejecutar `fitView` por cada refetch o cambio de estado del diálogo. Continúan desactivados drag, conexiones, reconexiones y borrado.
+
+### Picker, búsqueda y alta de activos
+
+Se reutilizan `AssetPickerList`, `AssetList`, `useAssetPickerData` (consultas extraídas del controller existente y compartidas por ambas selecciones), `FormDialog layout="picker"`, `PickerHelp` y `AssetEditorDialog`/`AssetForm` con su asistente real. No se duplica el formulario ni se omite la plantilla. La lista utiliza las consultas y cachés existentes de inventario, catálogo e infraestructura. El selector actual de activos es local sobre la respuesta existente del inventario; mantiene sus filtros por plantilla, búsqueda, columnas y paginación. No se introduce una descarga alternativa ni se sustituye un selector remoto por otro local.
+
+Solo se ofrecen activos de la compañía actual y se excluyen los miembros del snapshot abierto. Los activos no operativos u ocupados por otro puesto activo aparecen bloqueados conforme a las mismas reglas del editor. En puestos inactivos se mantiene la disponibilidad que ya admite el dominio. Se respeta el máximo de 200 miembros; el nodo deshabilitado informa del límite y la gestión existente permite retirar equipamiento.
+
+El alta se abre anidada: cancelarla conserva búsqueda y selección y devuelve el foco a «Crear activo nuevo». Al guardar, el activo permanece en inventario y vuelve seleccionado al picker. La búsqueda utiliza su nombre real para hacerlo visible incluso cuando el inventario tiene varias páginas. La asociación exige después «Añadir al puesto». Cancelar esa asociación no elimina el activo creado.
+
+### Revisión, persistencia e historial
+
+La inspección detectó que el guardado bloqueaba la compañía y las reservas, pero no verificaba la revisión que el usuario había editado. Se incorpora una precondición opcional `If-Match: ""` al guardado existente, sin cambiar el cuerpo `{ values }`. Se compara con la última revisión **dentro de la transacción y después de adquirir los bloqueos existentes**. Si difiere, devuelve `409 WORKSTATION_CHANGED` antes de modificar la composición o generar historial.
+
+El picker conserva la revisión de apertura aunque haya refetch en segundo plano. El editor tradicional captura también su revisión al abrir y el renombrado envía la revisión que acaba de leer. «Actualizar puesto y revisar selección» recupera el estado actual; el usuario revisa y confirma de nuevo. No hay rebase automático ni overwrite silencioso. Los clientes que no envían la cabecera conservan el contrato previo; no se ha convertido en una precondición obligatoria para integraciones existentes.
+
+Una asociación válida sigue creando la revisión normal de Workstation. El servidor continúa validando pertenencia a compañía, duplicados, estado, reservas y máximo de miembros. No se escribe el nodo de acción ni se cambia el principal. La comprobación unitaria de concurrencia verifica también que un guardado semánticamente idéntico con la revisión actual no añade historial.
+
+### Permisos y estados
+
+Se comprobó `createAssetModule`, `registerWorkstationRoutes` y `requireAuthenticated`: la edición de Workstation utiliza actualmente la misma sesión autenticada que la consulta del inventario; no existe un permiso de composición distinto ni un rol de lectura de puestos. Se reutiliza ese límite real, sin inventar permisos ni excluir arbitrariamente al usuario ordinario. El consumidor aporta `canEdit` y el mapa oculta la acción cuando es falso o el inventario no está disponible. El servidor sigue siendo la autoridad. Las pruebas SSR cubren `canEdit=false` y el preparador/recorrido HTTP comprueban `401` sin sesión. **No se afirma una prueba con un rol de solo lectura que el contrato actual no ofrece.**
+
+El puesto sin activos conserva su información de vacío y ofrece la acción tanto en desktop como en móvil. Sin principal se representan los miembros existentes, sin fabricar un ordenador central. Los errores parciales del dashboard mantienen su degradación anterior.
+
+| Situación | Recuperación |
+| --- | --- |
+| Fallo de lectura del selector | Estado de error de la lista y Reintentar, manteniendo abierto el diálogo y la búsqueda. |
+| Fallo de validación/asociación | Mensaje anunciable, selección conservada y posibilidad de reintentar; el mapa no se modifica de forma optimista. |
+| Cambio concurrente | Rechazo del servidor y actualización explícita antes de confirmar otra selección. |
+| Fallo del formulario de alta | El formulario real conserva su validación y su gestión de errores. |
+| Alta correcta y asociación fallida/cancelada | El activo permanece en inventario; no se vuelve a crear ni se elimina como compensación. |
+| Asociación confirmada y refetch fallido | Se informa de que se guardó, el diálogo permanece abierto y Reintentar ejecuta exclusivamente la lectura. |
+
+El guardado correcto invalida únicamente `inventoryKeys.company(companyId)` y obtiene el inventario actual antes de cerrar. Se regeneran el modelo, los contadores y Dagre desde la respuesta leída. El retorno de foco utiliza el disparador original si sigue conectado o el botón actual del mapa si React Flow lo ha reemplazado.
+
+### Responsive y accesibilidad
+
+En menos de 640 px efectivos se utiliza la presentación compacta con la misma acción antes del software. Se comprobaron 1600, 1440, 1280, 1024, 768 y 390 px, incluyendo el ancho real con las navegaciones actuales. Los diálogos mantienen altura estable, búsqueda enfocada, selección en la primera columna y scroll interno; no aparece overflow horizontal global.
+
+El botón es accesible mediante ratón, Enter y Espacio, con foco visible, estado disabled y texto de límite. Los errores usan `role="alert"`; la lectura posterior al guardado informa mediante `role="status"`. Base UI gestiona Escape, foco inicial y aislamiento/restauración entre diálogos; no se añaden listeners o focus traps manuales. La acción tiene `pointer-events-auto` para seguir recibiendo clics con los nodos de React Flow no editables.
+
+Skills consultadas: `ux-inputs-and-forms` como principal para el flujo; `shadcn`, `frontend-design`, `vercel-composition-patterns` y `vercel-react-best-practices` para reutilización de primitivas, composición y estado. Se conservan los criterios de `ux-dashboard`/`ux-tables` aplicados en las iteraciones anteriores. No están disponibles los auditores especializados sugeridos por las skills; se utilizan las comprobaciones reales de teclado y navegador disponibles, sin instalar dependencias.
+
+### Pruebas y evidencias WD04.4
+
+Instalación desechable preparada con `scripts/visual-audit.py`, exclusivamente `metis_desk_test`, autenticada mediante el formulario real con un usuario ordinario. `visual-audit-volume.py` y `workstation-dashboard-fixtures.py`: 15 compañías; compañía principal con 59 personas, 135 activos y 73 recursos antes de las altas del recorrido, además de un puesto con 80 miembros y 60 perfiles. Las altas de WD04.4 amplían ese inventario; no se accede a bases habituales.
+
+Las pruebas nuevas de revisión y disponibilidad se incluyen en `pnpm --filter @metis/assets test`. Se amplían las pruebas de layout y ficha para acción autorizada/oculta, vacío, separación de contadores y aristas, ausencia de solapamientos y determinismo. `scripts/workplace-add-asset.test.py` extiende el recorrido MCP existente y utiliza HTTP real para verificar composición, principal, historial, aislamiento y conflictos.
+
+| Comprobación ejecutada | Resultado |
+| --- | --- |
+| `pnpm --filter @metis/assets typecheck` | Correcto. |
+| `pnpm --filter @metis/companies typecheck` | Correcto. |
+| `pnpm --filter @metis/web typecheck` | Correcto. |
+| `pnpm --filter @metis/assets test` | 29 archivos de pruebas aprobados, incluidos selector, formulario, Workstation, revisión y disponibilidad. |
+| `pnpm --filter @metis/companies test` | 49 archivos aprobados; persiste el fallo previo de `companies-page.test.tsx`, ajeno a WD04.4. Las pruebas de mapa, ficha, perfiles y software pasan. |
+| Pruebas dirigidas nuevas de revisión, disponibilidad, layout y ficha | Correctas. Se cubren autorización visual es/en, vacío y separación de la acción respecto al equipamiento. |
+| Biome sobre los 30 archivos TS/TSX/JSON de esta intervención | Correcto, sin avisos. |
+| ESLint sobre sus archivos TS/TSX | Correcto. |
+| `pnpm build` | Correcto; conserva el aviso de tamaño del chunk principal. |
+| `pnpm typecheck` global | Bloqueado por el error previo de `locale` opcional en `packages/shadcn-ui/src/components/calendar.tsx:165`. No se modifica vendor. |
+| `pnpm lint` / `pnpm check` globales | Fallan por deuda previa de código/formato/importaciones fuera de esta intervención. Las comprobaciones dirigidas quedan limpias. |
+| `pnpm test` global | Se detiene por el fallo previo de `modules/resources/src/api/application/resource-scope.test.ts`. |
+| Regresión `workplace-visual-map.test.py` | **7 recorridos aprobados**, 172,755 s: seis anchos, densidad, enlaces, gestión, soporte, parciales, principal único, cobertura/refetch, contraste y movimiento reducido. |
+| `workplace-add-asset.test.py` | **4 recorridos aprobados**, 95,685 s: asociación/cancelación/conflicto; seis anchos y móvil; alta/cancelación/error/reintento/refetch; límite visual y restricciones HTTP/MariaDB. |
+| `git diff --check` | Correcto. |
+
+Los recorridos verifican Enter, Espacio, búsqueda enfocada, selección por teclado, Escape y retorno de foco. La asociación móvil conserva la ausencia de principal. Tras el fallo de lectura posterior a un guardado, el contador instrumentado verifica **una sola escritura**. En la comprobación HTTP se observan `INVALID_MEMBERS`, `ASSET_IN_WORKSTATION`, `ASSET_NOT_FOUND` y `401` sin sesión. Los cuatro recorridos WD04.4 terminan sin errores JavaScript de página no capturados.
+
+Evidencias locales revisadas visualmente, bajo `.artifacts/playwright/authenticated-c1voaeu5/workstation-dashboard-wd043/`:
+
+- `visual-map-iteration-2/after/map-{1600,1440,1280,1024,768,390}.png`: mapa con acción. Anchos reales 915, 755, 887, 872, 688 y 326 px; canvas de 424, 405, 424, 424 y 468 px, y presentación compacta en móvil. Sin overflow global.
+- `wd044-add-asset/picker-{1600,1440,1280,1024,768,390}.png`: selector en los seis tamaños.
+- `wd044-add-asset/S01-associated.png`, `S02-S10-mobile-associated.png`, `S04-create-form.png`, `S04-created-selected.png`, `S06-conflict.png`, `S07-save-error.png`, `S07-saved-refresh-error.png`, `S08-limit.png`.
+- Ambos directorios conservan `measurements.json` con geometría, resultados y hashes de las capturas. Las capturas quedan fuera de Git; no se versiona ni se copia la sesión privada.
+
+Para repetir: iniciar `python3 scripts/visual-audit.py`, mantener abierto su entorno, ejecutar `visual-audit-volume.py` y `workstation-dashboard-fixtures.py` con el `METIS_PLAYWRIGHT_STORAGE_STATE` anunciado por el preparador, y después `workplace-visual-map.test.py` y `workplace-add-asset.test.py`. Ejecutar la regresión antes de los recorridos de asociación, que modifican las composiciones sintéticas. Cerrar el preparador al terminar retira exclusivamente su instalación y borra la sesión.
+
+Limitaciones: no existe un rol de solo lectura de Workstation con el que ejecutar S09 como usuario autenticado; se cubren la prop de autorización y el rechazo anónimo real. El formulario de alta conserva sus validaciones y errores existentes, pero esta auditoría no fuerza un fallo HTTP de creación. Las comprobaciones globales no están verdes por los fallos previos indicados. Se observaron avisos de claves duplicadas en flujos anteriores del inventario durante la preparación/regresión; ya aparecían al preparar la sesión, antes del flujo WD04.4, y no se amplía el alcance para corregirlos.
+
+El preparador de esta ejecución se cerró al finalizar: instalación temporal retirada y ausencia de `session.json` verificada; las capturas se conservan.
diff --git a/docs/puestos-de-trabajo.md b/docs/puestos-de-trabajo.md
index 508ca560..eb325b81 100644
--- a/docs/puestos-de-trabajo.md
+++ b/docs/puestos-de-trabajo.md
@@ -178,3 +178,9 @@ criterio que la ficha de persona: no incluye puestos usados solo mediante un per
responsabilidades históricas, puestos desactivados ni registros de otra compañía.
La ausencia de puestos muestra `—`; el inventario se consulta una vez por compañía
mediante la caché compartida, con estado de carga y recuperación explícita de errores.
+
+## Sistema operativo actual del principal
+
+La ficha muestra un resumen compartido con la del activo: todas las instalaciones `configured` del principal con referencia resuelta de tipo `os`. Los estados pendientes o retirados no se presentan como SO actual. Sin registro aparece «No registrado»; carga, error recuperable y referencias no disponibles se distinguen. No se deduce el dato del SO de fábrica ni se copia en Workstation, WorkProfile o ApplicationConfiguration.
+
+Cambiar el ordenador principal selecciona inmediatamente las instalaciones del nuevo activo sin transferir software, licencias o historial. La gestión existente sigue disponible, y el mapa continúa mostrando el conjunto de software abierto con sus estados. La lista completa de SO actuales está en el resumen, incluso cuando hay varios productos. Véase la [decisión y validación](architecture/decision-so-actual-en-activos-y-puestos.md).
diff --git a/docs/software-del-ordenador.md b/docs/software-del-ordenador.md
index b4d51dae..a492a3a4 100644
--- a/docs/software-del-ordenador.md
+++ b/docs/software-del-ordenador.md
@@ -46,3 +46,11 @@ El uso `license` permite reconocer también plantillas propias y copias de la pl
Se comprueban referencias de otra compañía o de tipo incorrecto, inactividad/caducidad, idempotencia, desvinculación, revisiones anteriores, migración repetida y enlaces/traducciones es/en. La limpieza de la base exclusiva de pruebas elimina primero las instalaciones y revisiones para permitir ejecuciones consecutivas sin desactivar claves foráneas.
La elegibilidad del activo utiliza el indicador explícito `notRetired`. Un equipo en uso o en mantenimiento puede conservar y documentar software; la asignación exclusiva a un puesto aplica una regla distinta, definida en [Inventario de activos](inventario-de-activos.md).
+
+## Resumen del sistema operativo actual
+
+La ficha del activo muestra el resumen tras sus características técnicas; la ficha del puesto muestra la misma información de su ordenador principal antes de la gestión de software. Ambas consumen el mismo selector y caché. Se consideran únicamente instalaciones de ese activo y compañía en estado `configured` cuyo recurso pueda resolverse como `os`. Se muestran todos los productos, ordenados de forma estable, con versión cuando no esté ya incluida literalmente en su nombre. No se leen los campos del SO de fábrica ni se guardan copias en activo/puesto.
+
+Sin SO configurado se muestra «No registrado», que no confirma ausencia física. La carga usa skeleton; los errores tienen reintento y no se convierten en vacío. Una referencia configurada no resoluble muestra un aviso, ya que tampoco se puede determinar su tipo. Se conservan las reglas de ámbito y resolución de productos inactivos del backend.
+
+El resumen se aplica también a servidores sin puesto y plantillas de activo propias: no infiere SO desde firmware ni depende de UUID de plantilla. La gestión sigue en el panel/formulario existente de instalaciones de la ficha del activo y en el diálogo del puesto. El mapa conserva el software abierto y sus estados, incluyendo pendientes; el resumen es la proyección específica de SO configurados. Véase la [decisión aprobada y sus pruebas](architecture/decision-so-actual-en-activos-y-puestos.md).
diff --git a/modules/assets/package.json b/modules/assets/package.json
index e1f134ce..44e5a4bc 100644
--- a/modules/assets/package.json
+++ b/modules/assets/package.json
@@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
- "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/asset-template-propagation.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/assets/shared/helpers/asset-columns.test.tsx src/web/assets/detail/ui/asset-detail.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-membership.test.ts src/api/infrastructure/persistence/directory-membership-provider.test.ts src/web/directory-memberships/shared/directory-memberships.test.tsx src/api/domain/network-share-publication.test.ts src/web/network-share-publications/shared/network-share-ui.test.tsx"
+ "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/asset-template-propagation.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/assets/shared/helpers/asset-columns.test.tsx src/web/assets/detail/ui/asset-detail.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-membership.test.ts src/api/infrastructure/persistence/directory-membership-provider.test.ts src/web/directory-memberships/shared/directory-memberships.test.tsx src/api/domain/network-share-publication.test.ts src/web/network-share-publications/shared/network-share-ui.test.tsx src/api/application/workstation-concurrency.test.ts src/web/workstations/shared/helpers/workstation-asset-availability.test.ts"
},
"dependencies": {
"@hookform/resolvers": "5.2.2",
diff --git a/modules/assets/src/api/application/workstation-concurrency.test.ts b/modules/assets/src/api/application/workstation-concurrency.test.ts
new file mode 100644
index 00000000..c4e28242
--- /dev/null
+++ b/modules/assets/src/api/application/workstation-concurrency.test.ts
@@ -0,0 +1,102 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { UniqueID } from "@repo/rdx-ddd";
+import { Result } from "@repo/rdx-utils";
+
+import { AssetCatalog } from "../domain/asset-catalog.aggregate";
+import { WorkstationValues } from "../domain/value-objects/workstation-values.vo";
+import { Workstation } from "../domain/workstation.aggregate";
+
+import type { IAssetRepository, InventoryContext } from "./asset.repository";
+import { WorkstationUseCases } from "./workstation.use-cases";
+
+test("WD04.4: revisión obsoleta rechazada antes de reemplazar composición; revisión actual conserva historial", async () => {
+ const company = UniqueID.generateNewID();
+ const actor = UniqueID.generateNewID();
+ const values = WorkstationValues.create({
+ name: "Puesto vacío",
+ active: false,
+ siteId: null,
+ personId: null,
+ computerAssetId: null,
+ assetIds: [],
+ });
+
+ assert.ok(values.isSuccess);
+ const created = Workstation.create(company, values.data, actor, "2026-01-01T10:00:00.000Z");
+
+ assert.ok(created.isSuccess);
+ let current = created.data;
+
+ const context = (): InventoryContext => ({
+ workstations: [current],
+ assets: [],
+ catalog: AssetCatalog.create(),
+ brands: [],
+ relationships: [],
+ sites: [],
+ people: [],
+ actorNames: new Map(),
+ });
+
+ const unused = () => {
+ throw new Error("Operación fuera del caso de prueba");
+ };
+
+ const repository: IAssetRepository = {
+ get: unused,
+ save: unused,
+ remove: unused,
+ removeWorkstation: unused,
+ saveRelationship: unused,
+ removeRelationship: unused,
+ saveWorkstation: async (companyId, action) => {
+ assert.ok(companyId.equals(company));
+ const result = action(context());
+
+ if (!result.isSuccess) {
+ return Result.fail(result.error);
+ }
+ current = result.data;
+
+ return Result.ok({ workstation: current, actorNames: new Map() });
+ },
+ };
+ const useCases = new WorkstationUseCases(repository);
+ const revision = current.history.at(-1)?.id.toPrimitive();
+ const original = current;
+ const next = { ...values.data.toPrimitive(), name: "Otro usuario" };
+ const updated = await useCases.save(
+ company.toPrimitive(),
+ current.id.toPrimitive(),
+ next,
+ actor.toPrimitive(),
+ revision
+ );
+
+ assert.ok(updated.isSuccess);
+ assert.equal(current.history.length, 2);
+ const rejected = await useCases.save(
+ company.toPrimitive(),
+ current.id.toPrimitive(),
+ original.values.toPrimitive(),
+ actor.toPrimitive(),
+ revision
+ );
+
+ assert.ok(!rejected.isSuccess);
+ assert.equal(rejected.error.code, "WORKSTATION_CHANGED");
+ assert.equal(current.values.name.toPrimitive(), "Otro usuario");
+ assert.equal(current.history.length, 2);
+ const same = await useCases.save(
+ company.toPrimitive(),
+ current.id.toPrimitive(),
+ next,
+ actor.toPrimitive(),
+ current.history.at(-1)?.id.toPrimitive()
+ );
+
+ assert.ok(same.isSuccess);
+ assert.equal(current.history.length, 2);
+});
diff --git a/modules/assets/src/api/application/workstation.use-cases.ts b/modules/assets/src/api/application/workstation.use-cases.ts
index aa816eda..106f7b35 100644
--- a/modules/assets/src/api/application/workstation.use-cases.ts
+++ b/modules/assets/src/api/application/workstation.use-cases.ts
@@ -40,7 +40,13 @@ export class WorkstationUseCases {
});
}
- save(companyId: string, id: string | undefined, input: WorkstationInput, actorId: string) {
+ save(
+ companyId: string,
+ id: string | undefined,
+ input: WorkstationInput,
+ actorId: string,
+ expectedRevisionId?: string
+ ) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);
const identity = id ? UniqueID.create(id) : undefined;
@@ -57,6 +63,9 @@ export class WorkstationUseCases {
if (id && !previous) {
return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND"));
}
+ if (expectedRevisionId && previous?.history.at(-1)?.id.toPrimitive() !== expectedRevisionId) {
+ return Result.fail(new AssetCatalogError("WORKSTATION_CHANGED"));
+ }
const values = mapWorkstationInput(input, context, previous);
if (!values.isSuccess) {
diff --git a/modules/assets/src/api/infrastructure/http/workstation.routes.ts b/modules/assets/src/api/infrastructure/http/workstation.routes.ts
index 5c10e504..c8ec8d26 100644
--- a/modules/assets/src/api/infrastructure/http/workstation.routes.ts
+++ b/modules/assets/src/api/infrastructure/http/workstation.routes.ts
@@ -26,6 +26,11 @@ export function registerWorkstationRoutes(
path.endsWith("update") ? AssetParamsSchema : InventoryParamsSchema
).safeParse(request.params);
const body = SaveWorkstationRequestSchema.safeParse(request.body);
+ const revision = z
+ .string()
+ .regex(/^"[0-9a-f-]{36}"$/i)
+ .optional()
+ .safeParse(request.get("if-match"));
const actor = z
.object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined);
@@ -34,6 +39,7 @@ export function registerWorkstationRoutes(
!(
params.success &&
body.success &&
+ revision.success &&
actor.success &&
z.strictObject({}).safeParse(request.query).success
)
@@ -46,7 +52,13 @@ export function registerWorkstationRoutes(
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
useCases
- .save(params.data.companyId, id, body.data.values, actor.data.userId)
+ .save(
+ params.data.companyId,
+ id,
+ body.data.values,
+ actor.data.userId,
+ revision.data?.slice(1, -1)
+ )
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json
index e570fd28..c1af521d 100644
--- a/modules/assets/src/common/locales/en.json
+++ b/modules/assets/src/common/locales/en.json
@@ -154,7 +154,8 @@
"FORBIDDEN": "Sign in again.",
"UNAUTHENTICATED": "Sign in again.",
"ASSET_ASSIGNED": "Unlink the asset from active workstations before retiring it.",
- "ASSET_NOT_OPERATIONAL": "Only operational assets can be added to an active workstation."
+ "ASSET_NOT_OPERATIONAL": "Only operational assets can be added to an active workstation.",
+ "WORKSTATION_CHANGED": "Another user changed this workstation. Refresh and review your selection before saving."
},
"workstations": "Workstations",
"newWorkstation": "New workstation",
@@ -843,5 +844,19 @@
"currentAssignments_other": "{{count}} current assignments",
"primaryAssignment": "Primary",
"openResponsiblePerson": "Open person details"
+ },
+ "workstationAdd": {
+ "title": "Add asset to workstation",
+ "action": "Add asset",
+ "subtitle": "Select or create",
+ "description": "Select an asset belonging to this company to add it to the workstation.",
+ "confirm": "Add to workstation",
+ "create": "Create new asset",
+ "help": "Assets already associated are excluded. Creating an asset adds it to inventory; confirm its association afterwards. The main computer is preserved.",
+ "limit": "This workstation has reached its 200 asset limit. Manage equipment to remove an asset.",
+ "reload": "Refresh workstation and review selection",
+ "savedRefreshError": "The asset was associated, but the map could not be refreshed. Retry the refresh; saving again is unnecessary.",
+ "refreshing": "Asset associated. Refreshing the map…",
+ "helpLabel": "About asset association"
}
}
diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json
index d4f98928..fdc632b7 100644
--- a/modules/assets/src/common/locales/es.json
+++ b/modules/assets/src/common/locales/es.json
@@ -154,7 +154,8 @@
"FORBIDDEN": "Vuelve a iniciar sesión.",
"UNAUTHENTICATED": "Vuelve a iniciar sesión.",
"ASSET_ASSIGNED": "Desvincula el activo de los puestos activos antes de retirarlo.",
- "ASSET_NOT_OPERATIONAL": "Solo se pueden incorporar activos operativos a un puesto activo."
+ "ASSET_NOT_OPERATIONAL": "Solo se pueden incorporar activos operativos a un puesto activo.",
+ "WORKSTATION_CHANGED": "Otro usuario ha modificado el puesto. Actualiza los datos y revisa la selección antes de guardar."
},
"workstations": "Puestos de trabajo",
"newWorkstation": "Nuevo puesto",
@@ -843,5 +844,19 @@
"currentAssignments_other": "{{count}} asignaciones vigentes",
"primaryAssignment": "Principal",
"openResponsiblePerson": "Abrir ficha de persona"
+ },
+ "workstationAdd": {
+ "title": "Añadir activo al puesto",
+ "action": "Añadir activo",
+ "subtitle": "Seleccionar o crear",
+ "description": "Selecciona un activo de esta compañía para incorporarlo al puesto.",
+ "confirm": "Añadir al puesto",
+ "create": "Crear activo nuevo",
+ "help": "Los activos ya asociados no aparecen. Crear un activo lo registra en inventario; confirma después su asociación. El ordenador principal se conserva.",
+ "limit": "El puesto ha alcanzado el límite de 200 activos. Gestiona el equipamiento para retirar alguno.",
+ "reload": "Actualizar puesto y revisar selección",
+ "savedRefreshError": "El activo se ha asociado, pero no se pudo actualizar el mapa. Reintenta la actualización; no es necesario volver a guardar.",
+ "refreshing": "Activo asociado. Actualizando el mapa…",
+ "helpLabel": "Ayuda sobre la asociación"
}
}
diff --git a/modules/assets/src/web/assets/detail/types/asset-detail-props.ts b/modules/assets/src/web/assets/detail/types/asset-detail-props.ts
index d6996e3b..0b88fb71 100644
--- a/modules/assets/src/web/assets/detail/types/asset-detail-props.ts
+++ b/modules/assets/src/web/assets/detail/types/asset-detail-props.ts
@@ -8,4 +8,5 @@ export type AssetDetailProps = {
lookups: InventoryLookups;
compact?: boolean;
children?: ReactNode;
+ technicalSummary?: ReactNode;
};
diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail-content.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail-content.tsx
index 803c12cb..3d991ec8 100644
--- a/modules/assets/src/web/assets/detail/ui/asset-detail-content.tsx
+++ b/modules/assets/src/web/assets/detail/ui/asset-detail-content.tsx
@@ -19,6 +19,7 @@ export function AssetDetailContent({
assets,
relationships,
children,
+ technicalSummary,
}: AssetDetailProps & {
assets: readonly AssetDTO[];
relationships: readonly RelationshipDTO[];
@@ -46,7 +47,7 @@ export function AssetDetailContent({
value={tab}
/>
-
+ ReactNode;
+ technicalSummary?: (asset: AssetDTO) => ReactNode;
}) {
const { t } = useTranslation("assets");
const { searchParams } = useBrowserSearchParams();
@@ -107,6 +109,7 @@ export function AssetDetailPage({
assets={c.inventory.data?.assets ?? []}
lookups={lookups}
relationships={c.inventory.data?.relationships ?? []}
+ technicalSummary={technicalSummary?.(asset)}
>
{relatedContent?.(asset)}
diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail.test.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail.test.tsx
index 18d4172b..daab63bc 100644
--- a/modules/assets/src/web/assets/detail/ui/asset-detail.test.tsx
+++ b/modules/assets/src/web/assets/detail/ui/asset-detail.test.tsx
@@ -166,6 +166,7 @@ test("ficha de activo es/en: página con dos pestañas, información actual, rel
companyId={companyId}
lookups={lookups}
relatedContent={() =>