From 2967ea4f4d3ce0f1207e25d68bab2169fdc52ac1 Mon Sep 17 00:00:00 2001 From: monomono Date: Sun, 16 Aug 2026 01:41:27 -0500 Subject: [PATCH] #49 feat: reemplazar cytoscape por vis-network con semantica de certeza en graficos de provenance --- .eslintrc.js | 1 - AGENTS.md | 12 +- package-lock.json | 107 +++++++++++-- package.json | 2 +- src/components/graph/CytoscapeChart.vue | 75 ---------- src/components/graph/VisChart.vue | 127 ++++++++++++++++ .../ProductSupplierOrganizationChart.vue | 61 ++------ .../provenance/ProductTerritoryChart.vue | 63 ++------ src/components/provenance/provenance-graph.js | 121 +++++++++------ src/components/provenance/provenance-vis.js | 45 ++++++ src/services/graph/graph-layout.js | 76 ---------- .../components/graph/CytoscapeChart.spec.js | 93 ------------ tests/unit/components/graph/VisChart.spec.js | 121 +++++++++++++++ .../ProductSupplierOrganizationChart.spec.js | 91 +++++++++--- .../provenance/ProductTerritoryChart.spec.js | 74 ++++++--- .../provenance/ProvenanceSection.spec.js | 16 +- .../provenance/provenance-graph.spec.js | 140 ++++++++++++++---- .../unit/services/graph/graph-layout.spec.js | 100 ------------- 18 files changed, 732 insertions(+), 593 deletions(-) delete mode 100644 src/components/graph/CytoscapeChart.vue create mode 100644 src/components/graph/VisChart.vue create mode 100644 src/components/provenance/provenance-vis.js delete mode 100644 src/services/graph/graph-layout.js delete mode 100644 tests/unit/components/graph/CytoscapeChart.spec.js create mode 100644 tests/unit/components/graph/VisChart.spec.js delete mode 100644 tests/unit/services/graph/graph-layout.spec.js diff --git a/.eslintrc.js b/.eslintrc.js index 8dbbc61..9f918c7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,7 +34,6 @@ module.exports = { '!src/components/provenance/*.vue', '!src/components/provenance/**/*.vue', '!src/components/graph/*.vue', - '!src/services/graph/*.js', '!src/pages/pedido/*.vue', '!src/pages/pedido/**/*.vue', ], diff --git a/AGENTS.md b/AGENTS.md index c802b38..1ab464b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ src/ │ ├── order/ # Componentes del resumen de pedido público │ ├── provenance/ # Provenance (público + admin): gráficos, sección y CRUD admin │ │ └── admin/ # Organizaciones, Proveedores, Geografía, SupplierLinkDialog -│ └── graph/ # CytoscapeChart.vue (wrapper genérico de cytoscape) +│ └── graph/ # VisChart.vue (wrapper genérico de vis-network) ├── layouts/ # Layouts de página ├── pages/ # Vistas (auto-routed desde文件名) │ └── admin/ # Páginas admin (products, organizations, suppliers, geography, ...) @@ -28,7 +28,6 @@ src/ │ ├── api-implementation.js # Factory que selecciona implementación │ ├── auth.js # Manejo de auth (login, tokens JWT) │ ├── django-api.js # Implementación de API para Django -│ ├── graph/ # Genérico de grafos: graph-layout.js (layout por columnas + cytoscape) │ └── http.js # Axios instance con interceptors ├── stores/ # Pinia stores └── styles/ # SCSS settings @@ -97,7 +96,7 @@ No hay un estilo mayoritario. El código histórico está partido: - Tiene `ignorePatterns` masivo: `src/**` excepto los archivos nuevos de la tarea (`!src/components/order/**`, `!src/components/PublicOrderSummary.vue`, `!src/components/provenance/**`, `!src/components/graph/*.vue`, - `!src/services/graph/*.js`, `!src/pages/pedido/**`) + `!src/pages/pedido/**`) - **Los archivos nuevos deben seguir StandardJS** para quedar lint-eados: - 2 espacios (indent), sin semicolons, comillas simples - `function () {}` con espacio, `const f = (x) => x` (arrow-parens en args únicos NO) @@ -177,9 +176,8 @@ No hay un estilo mayoritario. El código histórico está partido: - Los gráficos se renderizan en el resumen público (`ProvenanceSection` dentro de `PublicOrderSummary.vue`) a partir de `product_provenance` embebido en el resumen (`GET /don_confiao/resumen_publico/`). El backend NO genera los gráficos. - Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`. - **Genérico reutilizable:** - - `src/services/graph/graph-layout.js`: `computeColumnLayout` (layout por columnas puro) y `toCytoscapeElements` (adapta nodos/aristas a cytoscape, añade clase `has-image` si el nodo tiene imagen) - - `src/components/graph/CytoscapeChart.vue`: wrapper de cytoscape.js (props `elements`, `styles` (map a `style` en cytoscape), `layout`, `height`; emite `select` con `node.data()`) -- **Específico público** (`src/components/provenance/`): builders puros en `provenance-graph.js` (`buildSupplierOrganizationGraph`, `buildTerritoryGraph`, `hasAnySupplier`, `hasAnyTerritory`), los charts (que muestran "próximamente estará disponible" cuando no hay relaciones), `ProvenanceDetailModal.vue` y `ProvenanceSection.vue` + - `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto +- **Específico público** (`src/components/provenance/`): builders puros en `provenance-graph.js` (`buildSupplierOrganizationGraph`, `buildTerritoryGraph`, `hasAnySupplier`, `hasAnyTerritory`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Los charts (que muestran "próximamente estará disponible" cuando no hay relaciones), `ProvenanceDetailModal.vue` y `ProvenanceSection.vue` - **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue` - **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//` -- Los tests mockean cytoscape (`vi.mock('cytoscape', ...)`) y la API con `global.provide: { api }` +- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }` diff --git a/package-lock.json b/package-lock.json index f5a22c9..4a1a6e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,10 @@ "@mdi/font": "7.4.47", "axios": "^1.13.5", "core-js": "^3.37.1", - "cytoscape": "^3.34.1", "leaflet": "^1.9.4", "roboto-fontface": "*", "vee-validate": "^4.14.6", + "vis-network": "^10.1.1", "vue": "^3.4.31", "vuetify": "^3.6.11" }, @@ -393,6 +393,18 @@ "node": ">=18" } }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "peer": true, + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1440,6 +1452,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "peer": true + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -2656,6 +2674,18 @@ "node": ">=14" } }, + "node_modules/component-emitter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz", + "integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2753,14 +2783,6 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, - "node_modules/cytoscape": { - "version": "3.34.1", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.1.tgz", - "integrity": "sha512-Lr0RvH9H75y9ar8h9Toy6u4lxRSCcxUq+hHcQ26sVWo6BnaQp1gwEZOYqwuYTZhyW7npyKnNLP8oJ2p1/3OZ7g==", - "engines": { - "node": ">=0.10" - } - }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -4991,6 +5013,12 @@ "json5": "lib/cli.js" } }, + "node_modules/keycharm": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz", + "integrity": "sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==", + "peer": true + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7045,6 +7073,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "peer": true, + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vee-validate": { "version": "4.14.6", "resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-4.14.6.tgz", @@ -7079,6 +7120,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/vis-data": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.5.tgz", + "integrity": "sha512-tRQKcTGaclo60rTY1j4v7BgD9v5jfD3wl+JgR1q6I4AI7go3QH5OjMCLIuZPpL2sf4IRMlwyPlRLnK4m+Phjsw==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0 || ^14.0.0", + "vis-util": ">=6.0.0" + } + }, + "node_modules/vis-network": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/vis-network/-/vis-network-10.1.1.tgz", + "integrity": "sha512-KCpQijl3DRasx5OSWaW+niW04ej6cczsjuNI+ZPrn1FItwwVHUnQXuiDoqj0H7EpvEd4JbbMKNLsFt1hhoQODQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "@egjs/hammerjs": "^2.0.0", + "component-emitter": "^1.3.0 || ^2.0.0", + "keycharm": "^0.2.0 || ^0.3.0 || ^0.4.0", + "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0 || ^14.0.0", + "vis-data": ">=8.0.0", + "vis-util": ">=6.0.0" + } + }, + "node_modules/vis-util": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.2.tgz", + "integrity": "sha512-sYawqWllCqwmTPLurRgPChmfRppbWI/XoBWdGNM6N2s4XuVR8VO8gtz/v9AvrAAOfZxel/U5UBNOwyhFn0qb7A==", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "@egjs/hammerjs": "^2.0.0", + "component-emitter": "^1.3.0 || ^2.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index a6a6774..e5ad862 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,10 @@ "@mdi/font": "7.4.47", "axios": "^1.13.5", "core-js": "^3.37.1", - "cytoscape": "^3.34.1", "leaflet": "^1.9.4", "roboto-fontface": "*", "vee-validate": "^4.14.6", + "vis-network": "^10.1.1", "vue": "^3.4.31", "vuetify": "^3.6.11" }, diff --git a/src/components/graph/CytoscapeChart.vue b/src/components/graph/CytoscapeChart.vue deleted file mode 100644 index ced6cc4..0000000 --- a/src/components/graph/CytoscapeChart.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/src/components/graph/VisChart.vue b/src/components/graph/VisChart.vue new file mode 100644 index 0000000..8957898 --- /dev/null +++ b/src/components/graph/VisChart.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/components/provenance/ProductSupplierOrganizationChart.vue b/src/components/provenance/ProductSupplierOrganizationChart.vue index 58f32da..e87a7ad 100644 --- a/src/components/provenance/ProductSupplierOrganizationChart.vue +++ b/src/components/provenance/ProductSupplierOrganizationChart.vue @@ -10,13 +10,13 @@ disponible próximamente. - @@ -30,10 +30,10 @@ diff --git a/src/components/provenance/ProductTerritoryChart.vue b/src/components/provenance/ProductTerritoryChart.vue index a30ea35..12289f5 100644 --- a/src/components/provenance/ProductTerritoryChart.vue +++ b/src/components/provenance/ProductTerritoryChart.vue @@ -10,13 +10,13 @@ disponible próximamente. - @@ -30,10 +30,10 @@ diff --git a/src/components/provenance/provenance-graph.js b/src/components/provenance/provenance-graph.js index dc051b3..54dd22d 100644 --- a/src/components/provenance/provenance-graph.js +++ b/src/components/provenance/provenance-graph.js @@ -2,14 +2,17 @@ * Builders de grafos de provenance (específicos del dominio). * * Convierten el payload `product_provenance` del resumen de compra/pedido en - * un grafo en capas usando el servicio genérico `services/graph/graph-layout`. + * un grafo con la semántica de certeza: + * - Arista cierta (continua): el destino es inequívoco. + * - Arista dudosa (discontinua): el destino es una posibilidad entre varias. + * - Con varios proveedores para un producto se inserta un nodo de disyunción + * (junction): sólida hasta él y discontinua hacia cada proveedor. + * + * El modelo devuelto es agnóstico de la herramienta de renderizado: + * node: { id, kind, label, image, entity } + * edge: { from, to, certain } */ -import { computeColumnLayout } from '@/services/graph/graph-layout' - -const SUPPLIER_ORG_COLUMNS = { product: 0, supplier: 1, organization: 2 } -const TERRITORY_COLUMNS = { product: 0, supplier: 1, municipality: 2, department: 3, country: 4 } - function makeCollector () { const nodes = [] const edges = [] @@ -23,7 +26,7 @@ function makeCollector () { const node = { id, kind, - label: entity.name, + label: entity.name || '', image: entity.catalogue_images?.[0] || null, entity: { ...entity, kind }, } @@ -32,63 +35,89 @@ function makeCollector () { } return seen.get(id) }, - addEdge (from, to) { - edges.push({ from, to }) + addEdge (from, to, certain) { + edges.push({ from, to, certain: Boolean(certain) }) }, } } -function layout (collector, columns) { - return computeColumnLayout(collector.nodes, collector.edges, { - columnOf: node => columns[node.kind] ?? 0, - columnWidth: 220, - rowHeight: 72, - padding: 24, - }) +function distinctValues (items) { + const seen = new Map() + for (const item of items) { + if (item && !seen.has(item.id)) seen.set(item.id, item) + } + return [...seen.values()] +} + +function connectSuppliers (collector, product, relations) { + if (relations.length === 1) { + const supplier = collector.addNode('supplier', relations[0].supplier) + collector.addEdge(product.id, supplier.id, true) + return + } + const junction = collector.addNode('junction', { id: product.entity.id, name: '' }) + collector.addEdge(product.id, junction.id, true) + for (const rel of relations) { + const supplier = collector.addNode('supplier', rel.supplier) + collector.addEdge(junction.id, supplier.id, false) + } +} + +function isCertain (values) { + return values.length <= 1 } export function buildSupplierOrganizationGraph (provenance) { - const g = makeCollector() + const collector = makeCollector() for (const entry of provenance || []) { if (!entry.product) continue - const product = g.addNode('product', entry.product) - for (const rel of entry.suppliers || []) { - if (!rel.supplier) continue - const supplier = g.addNode('supplier', rel.supplier) - g.addEdge(product.id, supplier.id) - if (rel.organization) { - const organization = g.addNode('organization', rel.organization) - g.addEdge(supplier.id, organization.id) - } + const product = collector.addNode('product', entry.product) + const relations = (entry.suppliers || []).filter(rel => rel.supplier) + if (relations.length === 0) continue + connectSuppliers(collector, product, relations) + + const organizationCertain = isCertain(distinctValues(relations.map(rel => rel.organization))) + for (const rel of relations) { + if (!rel.organization) continue + const organization = collector.addNode('organization', rel.organization) + collector.addEdge(`supplier:${rel.supplier.id}`, organization.id, organizationCertain) } } - return layout(g, SUPPLIER_ORG_COLUMNS) + return { nodes: collector.nodes, edges: collector.edges } } export function buildTerritoryGraph (provenance) { - const g = makeCollector() + const collector = makeCollector() for (const entry of provenance || []) { if (!entry.product) continue - const product = g.addNode('product', entry.product) - for (const rel of entry.suppliers || []) { - if (!rel.supplier) continue - const supplier = g.addNode('supplier', rel.supplier) - g.addEdge(product.id, supplier.id) - if (rel.municipality) { - const municipality = g.addNode('municipality', rel.municipality) - g.addEdge(supplier.id, municipality.id) - if (rel.department) { - const department = g.addNode('department', rel.department) - g.addEdge(municipality.id, department.id) - if (rel.country) { - const country = g.addNode('country', rel.country) - g.addEdge(department.id, country.id) - } - } - } + const product = collector.addNode('product', entry.product) + const relations = (entry.suppliers || []).filter(rel => rel.supplier) + if (relations.length === 0) continue + connectSuppliers(collector, product, relations) + + const municipalityCertain = isCertain(distinctValues(relations.map(rel => rel.municipality))) + const departmentCertain = isCertain(distinctValues(relations.map(rel => rel.department))) + const countryCertain = isCertain(distinctValues(relations.map(rel => rel.country))) + + for (const rel of relations) { + if (!rel.municipality) continue + const municipality = collector.addNode('municipality', rel.municipality) + collector.addEdge(`supplier:${rel.supplier.id}`, municipality.id, municipalityCertain) + } + for (const rel of relations) { + if (!rel.municipality || !rel.department) continue + const municipality = collector.addNode('municipality', rel.municipality) + const department = collector.addNode('department', rel.department) + collector.addEdge(municipality.id, department.id, departmentCertain) + } + for (const rel of relations) { + if (!rel.department || !rel.country) continue + const department = collector.addNode('department', rel.department) + const country = collector.addNode('country', rel.country) + collector.addEdge(department.id, country.id, countryCertain) } } - return layout(g, TERRITORY_COLUMNS) + return { nodes: collector.nodes, edges: collector.edges } } export function hasAnySupplier (provenance) { diff --git a/src/components/provenance/provenance-vis.js b/src/components/provenance/provenance-vis.js new file mode 100644 index 0000000..8e19847 --- /dev/null +++ b/src/components/provenance/provenance-vis.js @@ -0,0 +1,45 @@ +/** + * Adapta el modelo de grafo de provenance ({ id, kind, label, image, entity } + * y { from, to, certain }) al formato de vis-network, incluyendo la semántica + * de certeza: arista cierta = sólida, arista dudosa = discontinua. + */ + +const COLORS = { + product: '#26a69a', + supplier: '#42a5f5', + organization: '#ffb74d', + municipality: '#66bb6a', + department: '#5c6bc0', + country: '#ab47bc', + junction: '#78909c', +} + +export function toVisNodes (nodes) { + return nodes.map(node => { + const color = COLORS[node.kind] || '#cfd8dc' + const { image, ...rest } = node + if (node.kind === 'junction') { + return { ...rest, shape: 'dot', color, size: 10, label: '' } + } + if (image) { + return { ...rest, image, shape: 'circularImage', borderWidth: 2 } + } + return { ...rest, shape: 'dot', color, borderWidth: 2 } + }) +} + +export function toVisEdges (edges) { + return edges.map(edge => ({ + from: edge.from, + to: edge.to, + dashes: !edge.certain, + })) +} + +export const chartOptions = { + nodes: { font: { size: 13, color: '#263238' } }, + edges: { color: { color: '#546e7a' } }, + physics: { + barnesHut: { gravitationalConstant: -4000, springLength: 120, springConstant: 0.02 }, + }, +} diff --git a/src/services/graph/graph-layout.js b/src/services/graph/graph-layout.js deleted file mode 100644 index ef635ef..0000000 --- a/src/services/graph/graph-layout.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Servicio genérico de grafos en capas (columnas). - * - * Provee un layout determinista por columnas (un tipo por columna) y un - * adaptador al formato `elements` de cytoscape.js. No conoce el dominio - * (provenance, organigrama, etc.), solo el modelo de grafo: - * node: { id, kind, label, image?, entity? } - * edge: { from, to } (ids de los nodos) - */ - -const DEFAULTS = { - columnWidth: 220, - rowHeight: 64, - padding: 24, -} - -function groupByColumn (nodes, columnOf) { - const columns = {} - for (const node of nodes) { - const column = columnOf(node) ?? 0 - if (!columns[column]) columns[column] = [] - columns[column].push(node) - } - return columns -} - -export function computeColumnLayout (nodes, edges, options = {}) { - const { columnOf, columnWidth = DEFAULTS.columnWidth, rowHeight = DEFAULTS.rowHeight, padding = DEFAULTS.padding } = options - const columns = groupByColumn(nodes, columnOf) - const columnKeys = Object.keys(columns).map(Number) - const maxColumn = columnKeys.length > 0 ? Math.max(...columnKeys) : 0 - const maxRows = columnKeys.length > 0 ? Math.max(...columnKeys.map(c => columns[c].length)) : 0 - - const positionedNodes = nodes.map(node => { - const column = columnOf(node) ?? 0 - const index = columns[column].indexOf(node) - return { - ...node, - x: padding + column * columnWidth + columnWidth / 2, - y: padding + index * rowHeight + rowHeight / 2, - } - }) - - return { - nodes: positionedNodes, - edges, - width: padding * 2 + (maxColumn + 1) * columnWidth, - height: padding * 2 + maxRows * rowHeight, - } -} - -export function toCytoscapeElements ({ nodes, edges }) { - return [ - ...nodes.map(node => { - const classes = node.image ? [node.kind, 'has-image'] : [node.kind] - return { - data: { - id: node.id, - kind: node.kind, - label: node.label, - image: node.image || null, - entity: node.entity || null, - }, - classes, - position: { x: node.x, y: node.y }, - } - }), - ...edges.map(edge => ({ - data: { - id: `edge:${edge.from}:${edge.to}`, - source: edge.from, - target: edge.to, - }, - })), - ] -} diff --git a/tests/unit/components/graph/CytoscapeChart.spec.js b/tests/unit/components/graph/CytoscapeChart.spec.js deleted file mode 100644 index 323d7b8..0000000 --- a/tests/unit/components/graph/CytoscapeChart.spec.js +++ /dev/null @@ -1,93 +0,0 @@ -import cytoscape from 'cytoscape' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' -import CytoscapeChart from '@/components/graph/CytoscapeChart.vue' - -const tapHandler = { current: null } -const cy = { - on: vi.fn((event, selector, handler) => { - if (event === 'tap') tapHandler.current = handler - }), - elements: vi.fn(() => ({ remove: vi.fn() })), - add: vi.fn(), - layout: vi.fn(() => ({ run: vi.fn() })), - destroy: vi.fn(), -} - -vi.mock('cytoscape', () => ({ - default: vi.fn(() => cy), -})) - -const elements = [ - { data: { id: 'product:1', kind: 'product', label: 'Panela' }, position: { x: 100, y: 100 } }, - { data: { id: 'supplier:5', kind: 'supplier', label: 'La Mesa' }, position: { x: 320, y: 100 } }, - { data: { id: 'e1', source: 'product:1', target: 'supplier:5' } }, -] - -const styles = [{ selector: 'node', style: { 'background-color': '#eee' } }] - -beforeEach(() => { - vi.clearAllMocks() - tapHandler.current = null -}) - -function mountChart (props = {}) { - return mount(CytoscapeChart, { - props: { elements, styles, ...props }, - }) -} - -describe('CytoscapeChart', () => { - it('crea la instancia de cytoscape con elements, styles y layout', () => { - mountChart() - - expect(cytoscape).toHaveBeenCalledTimes(1) - expect(cytoscape).toHaveBeenCalledWith( - expect.objectContaining({ - elements, - style: styles, - layout: { name: 'preset' }, - }) - ) - }) - - it('permite sobreescribir el layout con el prop layout', () => { - mountChart({ layout: { name: 'breadthfirst' } }) - - expect(cytoscape).toHaveBeenCalledWith( - expect.objectContaining({ layout: { name: 'breadthfirst' } }) - ) - }) - - it('emite select con los datos del nodo al hacer tap', () => { - const wrapper = mountChart() - - tapHandler.current({ target: { data: () => elements[0].data } }) - - expect(wrapper.emitted('select')).toEqual([[elements[0].data]]) - }) - - it('actualiza la instancia cuando cambian los elements', async () => { - const wrapper = mountChart() - const next = [{ data: { id: 'organization:3', kind: 'organization', label: 'Red' } }] - - await wrapper.setProps({ elements: next }) - - expect(cy.elements).toHaveBeenCalled() - expect(cy.add).toHaveBeenCalledWith(next) - expect(cy.layout).toHaveBeenCalled() - }) - - it('destruye la instancia al desmontarse', () => { - const wrapper = mountChart() - - wrapper.unmount() - - expect(cy.destroy).toHaveBeenCalledTimes(1) - }) - - it('no escucha eventos mientras no exista la instancia', () => { - mountChart() - expect(cy.on).toHaveBeenCalledWith('tap', 'node', expect.any(Function)) - }) -}) diff --git a/tests/unit/components/graph/VisChart.spec.js b/tests/unit/components/graph/VisChart.spec.js new file mode 100644 index 0000000..3331ffd --- /dev/null +++ b/tests/unit/components/graph/VisChart.spec.js @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import VisChart from '@/components/graph/VisChart.vue' + +const vis = vi.hoisted(() => { + const networkInstance = { + on: vi.fn(), + once: vi.fn(), + setData: vi.fn(), + setOptions: vi.fn(), + fit: vi.fn(), + destroy: vi.fn(), + } + const dataSetInstance = { + get: vi.fn(), + } + return { + networkInstance, + dataSetInstance, + Network: vi.fn(() => networkInstance), + DataSet: vi.fn(() => dataSetInstance), + } +}) + +vi.mock('vis-network/standalone', () => ({ + Network: vis.Network, + DataSet: vis.DataSet, +})) + +function handlerFor (mock, eventName) { + const call = mock.mock.calls.find(args => args[0] === eventName) + return call ? call[1] : null +} + +describe('VisChart', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('crea la red con los nodos y aristas recibidos', () => { + const nodes = [{ id: 'product:1', label: 'Panela' }] + const edges = [{ from: 'product:1', to: 'supplier:5', dashes: true }] + mount(VisChart, { props: { nodes, edges } }) + + expect(vis.Network).toHaveBeenCalledTimes(1) + const [container, , options] = vis.Network.mock.calls[0] + expect(container).toBeInstanceOf(HTMLElement) + expect(options.physics.stabilization.enabled).toBe(true) + expect(vis.dataSetInstance.get).toBeDefined() + const data = vis.networkInstance.setData.mock.calls[0][0] + expect(data.nodes).toBe(vis.dataSetInstance) + expect(data.edges).toBe(vis.dataSetInstance) + }) + + it('mezcla las opciones base con las recibidas', () => { + mount(VisChart, { + props: { + nodes: [], + edges: [], + options: { nodes: { font: { size: 16 } }, physics: { stabilization: { iterations: 100 } } }, + }, + }) + + const options = vis.Network.mock.calls[0][2] + expect(options.nodes.font.size).toBe(16) + expect(options.nodes.shape).toBe('dot') + expect(options.physics.stabilization.iterations).toBe(100) + expect(options.physics.barnesHut.springLength).toBe(140) + }) + + it('emite select con el nodo al hacer click', () => { + const wrapper = mount(VisChart, { props: { nodes: [{ id: 'supplier:5', kind: 'supplier' }], edges: [] } }) + + vis.dataSetInstance.get.mockReturnValue({ id: 'supplier:5', kind: 'supplier', label: 'La Mesa' }) + handlerFor(vis.networkInstance.on, 'click')({ nodes: ['supplier:5'] }) + + expect(wrapper.emitted('select')[0][0]).toEqual({ id: 'supplier:5', kind: 'supplier', label: 'La Mesa' }) + }) + + it('no emite select cuando el click no cae sobre un nodo', () => { + const wrapper = mount(VisChart, { props: { nodes: [], edges: [] } }) + + handlerFor(vis.networkInstance.on, 'click')({ nodes: [] }) + + expect(wrapper.emitted('select')).toBeUndefined() + }) + + it('desactiva la física y ajusta la vista cuando la estabilización termina', () => { + mount(VisChart, { props: { nodes: [], edges: [] } }) + + const onStabilized = handlerFor(vis.networkInstance.once, 'stabilizationIterationsDone') + expect(onStabilized).toBeDefined() + onStabilized() + + expect(vis.networkInstance.setOptions).toHaveBeenCalledWith({ physics: { enabled: false } }) + expect(vis.networkInstance.fit).toHaveBeenCalled() + }) + + it('actualiza la red cuando cambian los nodos o aristas', async () => { + const wrapper = mount(VisChart, { props: { nodes: [{ id: 'product:1' }], edges: [] } }) + expect(vis.networkInstance.setData).toHaveBeenCalledTimes(1) + + await wrapper.setProps({ nodes: [{ id: 'product:2' }], edges: [{ from: 'product:2', to: 'supplier:5' }] }) + + expect(vis.networkInstance.setData).toHaveBeenCalledTimes(2) + }) + + it('aplica la altura recibida al contenedor', () => { + const wrapper = mount(VisChart, { props: { height: 340 } }) + + expect(wrapper.element.style.height).toBe('340px') + }) + + it('destruye la red al desmontar', () => { + const wrapper = mount(VisChart, { props: { nodes: [], edges: [] } }) + + wrapper.unmount() + + expect(vis.networkInstance.destroy).toHaveBeenCalled() + }) +}) diff --git a/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js b/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js index b9d7a3c..3b5dcfd 100644 --- a/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js +++ b/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js @@ -1,20 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' -import CytoscapeChart from '@/components/graph/CytoscapeChart.vue' import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue' import ProductSupplierOrganizationChart from '@/components/provenance/ProductSupplierOrganizationChart.vue' import vuetify from '@/plugins/vuetify' -const cy = { - on: vi.fn(), - elements: vi.fn(() => ({ remove: vi.fn() })), - add: vi.fn(), - layout: vi.fn(() => ({ run: vi.fn() })), - destroy: vi.fn(), -} - -vi.mock('cytoscape', () => ({ - default: vi.fn(() => cy), +vi.mock('@/components/graph/VisChart.vue', () => ({ + default: { + name: 'VisChart', + template: '
', + props: ['nodes', 'edges', 'height', 'options'], + }, })) const provenance = [ @@ -36,6 +31,22 @@ const provenance = [ }, ] +const provenanceWithTwoSuppliers = [ + { + product: { id: 1, name: 'Panela regional por Kg' }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa' }, + organization: { id: 3, name: 'Red de Economía Solidaria' }, + }, + { + supplier: { id: 6, name: 'Asociación San Antonio' }, + organization: { id: 3, name: 'Red de Economía Solidaria' }, + }, + ], + }, +] + function mountChart (props = {}) { return mount(ProductSupplierOrganizationChart, { props: { provenance, ...props }, @@ -43,29 +54,58 @@ function mountChart (props = {}) { }) } +function visChart (wrapper) { + return wrapper.findComponent({ name: 'VisChart' }) +} + describe('ProductSupplierOrganizationChart', () => { beforeEach(() => { vi.clearAllMocks() }) - it('renderiza el gráfico con los elementos de productos, proveedores y organizaciones', () => { + it('renderiza el gráfico con nodos de productos, proveedores y organizaciones', () => { const wrapper = mountChart() - const chart = wrapper.findComponent(CytoscapeChart) + const chart = visChart(wrapper) expect(chart.exists()).toBe(true) - const elements = chart.props('elements') - const nodeIds = elements.filter(e => e.data.source === undefined).map(e => e.data.id) + const nodeIds = chart.props('nodes').map(node => node.id) expect(nodeIds).toContain('product:1') expect(nodeIds).toContain('supplier:5') expect(nodeIds).toContain('organization:3') }) - it('muestra el nombre de los nodos a través del label', () => { + it('incluye la etiqueta del nodo y la imagen circular para quien tiene foto', () => { const wrapper = mountChart() - const styles = wrapper.findComponent(CytoscapeChart).props('styles') - const nodeStyle = styles.find(style => style.selector === 'node').style - expect(nodeStyle.content).toBe('data(label)') + const nodes = visChart(wrapper).props('nodes') + const product = nodes.find(node => node.id === 'product:1') + expect(product.label).toBe('Panela regional por Kg') + expect(product.image).toBe('http://localhost/media/panela.jpg') + expect(product.shape).toBe('circularImage') + }) + + it('con varios proveedores crea el punto de disyunción: sólida hasta él y discontinua hacia cada proveedor', () => { + const wrapper = mountChart({ provenance: provenanceWithTwoSuppliers }) + + const chart = visChart(wrapper) + const nodes = chart.props('nodes') + const junction = nodes.find(node => node.id === 'junction:1') + expect(junction).toBeDefined() + expect(junction.shape).toBe('dot') + expect(junction.label).toBe('') + + const edges = chart.props('edges') + expect(edges.find(edge => edge.from === 'product:1' && edge.to === 'junction:1').dashes).toBe(false) + expect(edges.find(edge => edge.from === 'junction:1' && edge.to === 'supplier:5').dashes).toBe(true) + expect(edges.find(edge => edge.from === 'junction:1' && edge.to === 'supplier:6').dashes).toBe(true) + }) + + it('una organización compartida viaja en línea continua', () => { + const wrapper = mountChart({ provenance: provenanceWithTwoSuppliers }) + + const edges = visChart(wrapper).props('edges') + expect(edges.find(edge => edge.from === 'supplier:5' && edge.to === 'organization:3').dashes).toBe(false) + expect(edges.find(edge => edge.from === 'supplier:6' && edge.to === 'organization:3').dashes).toBe(false) }) it('no muestra mensaje de próximamente cuando existen relaciones', () => { @@ -79,17 +119,24 @@ describe('ProductSupplierOrganizationChart', () => { provenance: [{ product: { id: 1, name: 'Panela' }, suppliers: [] }], }) - expect(wrapper.findComponent(CytoscapeChart).exists()).toBe(false) + expect(visChart(wrapper).exists()).toBe(false) expect(wrapper.text()).toContain('próximamente') }) it('abre el modal de detalle al seleccionar un nodo', async () => { const wrapper = mountChart() - const chart = wrapper.findComponent(CytoscapeChart) - await chart.vm.$emit('select', { kind: 'supplier', label: 'Asociación Agropecuaria La Mesa', entity: { kind: 'supplier' } }) + await visChart(wrapper).vm.$emit('select', { id: 'supplier:5', kind: 'supplier', label: 'Asociación Agropecuaria La Mesa', entity: { kind: 'supplier' } }) expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true) expect(document.body.textContent).toContain('Asociación Agropecuaria La Mesa') }) + + it('ignora la selección del punto de disyunción', async () => { + const wrapper = mountChart({ provenance: provenanceWithTwoSuppliers }) + + await visChart(wrapper).vm.$emit('select', { id: 'junction:1', kind: 'junction', label: '' }) + + expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(false) + }) }) diff --git a/tests/unit/components/provenance/ProductTerritoryChart.spec.js b/tests/unit/components/provenance/ProductTerritoryChart.spec.js index 49a3ff8..191653c 100644 --- a/tests/unit/components/provenance/ProductTerritoryChart.spec.js +++ b/tests/unit/components/provenance/ProductTerritoryChart.spec.js @@ -1,20 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' -import CytoscapeChart from '@/components/graph/CytoscapeChart.vue' import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue' import ProductTerritoryChart from '@/components/provenance/ProductTerritoryChart.vue' import vuetify from '@/plugins/vuetify' -const cy = { - on: vi.fn(), - elements: vi.fn(() => ({ remove: vi.fn() })), - add: vi.fn(), - layout: vi.fn(() => ({ run: vi.fn() })), - destroy: vi.fn(), -} - -vi.mock('cytoscape', () => ({ - default: vi.fn(() => cy), +vi.mock('@/components/graph/VisChart.vue', () => ({ + default: { + name: 'VisChart', + template: '
', + props: ['nodes', 'edges', 'height', 'options'], + }, })) const provenance = [ @@ -32,6 +27,26 @@ const provenance = [ }, ] +const provenanceWithTwoSuppliers = [ + { + product: { id: 1, name: 'Panela regional por Kg' }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa' }, + municipality: { id: 7, name: 'La Mesa' }, + department: { id: 2, name: 'Cundinamarca' }, + country: { id: 1, name: 'Colombia' }, + }, + { + supplier: { id: 6, name: 'Asociación San Antonio' }, + municipality: { id: 8, name: 'San Antonio' }, + department: { id: 3, name: 'Antioquia' }, + country: { id: 1, name: 'Colombia' }, + }, + ], + }, +] + function mountChart (props = {}) { return mount(ProductTerritoryChart, { props: { provenance, ...props }, @@ -39,6 +54,10 @@ function mountChart (props = {}) { }) } +function visChart (wrapper) { + return wrapper.findComponent({ name: 'VisChart' }) +} + describe('ProductTerritoryChart', () => { beforeEach(() => { vi.clearAllMocks() @@ -47,10 +66,9 @@ describe('ProductTerritoryChart', () => { it('renderiza el gráfico con municipio, departamento y país', () => { const wrapper = mountChart() - const chart = wrapper.findComponent(CytoscapeChart) + const chart = visChart(wrapper) expect(chart.exists()).toBe(true) - const elements = chart.props('elements') - const nodeIds = elements.filter(e => e.data.source === undefined).map(e => e.data.id) + const nodeIds = chart.props('nodes').map(node => node.id) expect(nodeIds).toContain('product:1') expect(nodeIds).toContain('supplier:5') expect(nodeIds).toContain('municipality:7') @@ -58,12 +76,27 @@ describe('ProductTerritoryChart', () => { expect(nodeIds).toContain('country:1') }) - it('muestra el nombre de los nodos a través del label', () => { + it('incluye la etiqueta del nodo y el color según el tipo', () => { const wrapper = mountChart() - const styles = wrapper.findComponent(CytoscapeChart).props('styles') - const nodeStyle = styles.find(style => style.selector === 'node').style - expect(nodeStyle.content).toBe('data(label)') + const nodes = visChart(wrapper).props('nodes') + const municipality = nodes.find(node => node.id === 'municipality:7') + expect(municipality.label).toBe('La Mesa') + expect(municipality.color).toBe('#66bb6a') + }) + + it('con varios proveedores la duda se corta cuando comparten territorio', () => { + const wrapper = mountChart({ provenance: provenanceWithTwoSuppliers }) + + const chart = visChart(wrapper) + expect(chart.props('nodes').map(node => node.id)).toContain('junction:1') + + const edges = chart.props('edges') + expect(edges.find(edge => edge.from === 'product:1' && edge.to === 'junction:1').dashes).toBe(false) + expect(edges.find(edge => edge.from === 'junction:1' && edge.to === 'supplier:5').dashes).toBe(true) + expect(edges.find(edge => edge.from === 'supplier:5' && edge.to === 'municipality:7').dashes).toBe(true) + expect(edges.find(edge => edge.from === 'municipality:7' && edge.to === 'department:2').dashes).toBe(true) + expect(edges.find(edge => edge.from === 'department:2' && edge.to === 'country:1').dashes).toBe(false) }) it('muestra mensaje de próximamente cuando ningún proveedor tiene territorio', () => { @@ -76,15 +109,14 @@ describe('ProductTerritoryChart', () => { ], }) - expect(wrapper.findComponent(CytoscapeChart).exists()).toBe(false) + expect(visChart(wrapper).exists()).toBe(false) expect(wrapper.text()).toContain('próximamente') }) it('abre el modal de detalle al seleccionar un municipio', async () => { const wrapper = mountChart() - const chart = wrapper.findComponent(CytoscapeChart) - await chart.vm.$emit('select', { kind: 'municipality', label: 'La Mesa', entity: { kind: 'municipality', name: 'La Mesa' } }) + await visChart(wrapper).vm.$emit('select', { id: 'municipality:7', kind: 'municipality', label: 'La Mesa', entity: { kind: 'municipality', name: 'La Mesa' } }) expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true) expect(document.body.textContent).toContain('La Mesa') diff --git a/tests/unit/components/provenance/ProvenanceSection.spec.js b/tests/unit/components/provenance/ProvenanceSection.spec.js index b8c8612..fac6a29 100644 --- a/tests/unit/components/provenance/ProvenanceSection.spec.js +++ b/tests/unit/components/provenance/ProvenanceSection.spec.js @@ -5,16 +5,12 @@ import ProductTerritoryChart from '@/components/provenance/ProductTerritoryChart import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue' import vuetify from '@/plugins/vuetify' -const cy = { - on: vi.fn(), - elements: vi.fn(() => ({ remove: vi.fn() })), - add: vi.fn(), - layout: vi.fn(() => ({ run: vi.fn() })), - destroy: vi.fn(), -} - -vi.mock('cytoscape', () => ({ - default: vi.fn(() => cy), +vi.mock('@/components/graph/VisChart.vue', () => ({ + default: { + name: 'VisChart', + template: '
', + props: ['nodes', 'edges', 'height', 'options'], + }, })) const provenance = [ diff --git a/tests/unit/components/provenance/provenance-graph.spec.js b/tests/unit/components/provenance/provenance-graph.spec.js index 6b7944b..62298b4 100644 --- a/tests/unit/components/provenance/provenance-graph.spec.js +++ b/tests/unit/components/provenance/provenance-graph.spec.js @@ -6,7 +6,7 @@ import { hasAnyTerritory, } from '@/components/provenance/provenance-graph' -const provenance = [ +const singleSupplier = [ { product: { id: 1, @@ -25,9 +25,37 @@ const provenance = [ }, ] +const twoSuppliers = (overrides = {}) => [ + { + product: { id: 1, name: 'Panela' }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación A' }, + organization: { id: 3, name: 'Red' }, + municipality: { id: 7, name: 'La Mesa' }, + department: { id: 2, name: 'Cundinamarca' }, + country: { id: 1, name: 'Colombia' }, + ...(overrides.first || {}), + }, + { + supplier: { id: 6, name: 'Asociación B' }, + organization: { id: 3, name: 'Red' }, + municipality: { id: 8, name: 'San Antonio' }, + department: { id: 3, name: 'Antioquia' }, + country: { id: 1, name: 'Colombia' }, + ...(overrides.second || {}), + }, + ], + }, +] + +function edgeOf (graph, from, to) { + return graph.edges.find(edge => edge.from === from && edge.to === to) +} + describe('hasAnySupplier', () => { it('es true cuando algún producto tiene proveedores', () => { - expect(hasAnySupplier(provenance)).toBe(true) + expect(hasAnySupplier(singleSupplier)).toBe(true) }) it('es false cuando todos los productos están sin proveedores', () => { @@ -42,7 +70,7 @@ describe('hasAnySupplier', () => { describe('hasAnyTerritory', () => { it('es true cuando algún proveedor tiene territorio', () => { - expect(hasAnyTerritory(provenance)).toBe(true) + expect(hasAnyTerritory(singleSupplier)).toBe(true) }) it('es false cuando todos los territorios vienen en null', () => { @@ -62,20 +90,41 @@ describe('hasAnyTerritory', () => { }) describe('buildSupplierOrganizationGraph', () => { - it('crea nodos de producto, proveedor y organización con sus aristas', () => { - const graph = buildSupplierOrganizationGraph(provenance) + it('con un único proveedor conecta con arista cierta y sin junction', () => { + const graph = buildSupplierOrganizationGraph(singleSupplier) - const ids = graph.nodes.map(n => n.id) - expect(ids).toContain('product:1') - expect(ids).toContain('supplier:5') - expect(ids).toContain('organization:3') + expect(graph.nodes.map(n => n.id)).not.toContain('junction:1') + expect(edgeOf(graph, 'product:1', 'supplier:5')).toEqual({ from: 'product:1', to: 'supplier:5', certain: true }) + }) - expect(graph.edges).toContainEqual({ from: 'product:1', to: 'supplier:5' }) - expect(graph.edges).toContainEqual({ from: 'supplier:5', to: 'organization:3' }) + it('con varios proveedores crea un punto de disyunción: sólida hasta él y dudosa hacia cada proveedor', () => { + const graph = buildSupplierOrganizationGraph(twoSuppliers()) + + expect(graph.nodes.map(n => n.id)).toContain('junction:1') + expect(edgeOf(graph, 'product:1', 'junction:1')).toEqual({ from: 'product:1', to: 'junction:1', certain: true }) + expect(edgeOf(graph, 'junction:1', 'supplier:5')).toEqual({ from: 'junction:1', to: 'supplier:5', certain: false }) + expect(edgeOf(graph, 'junction:1', 'supplier:6')).toEqual({ from: 'junction:1', to: 'supplier:6', certain: false }) + }) + + it('una organización compartida por todos los proveedores es cierta', () => { + const graph = buildSupplierOrganizationGraph(twoSuppliers()) + + expect(edgeOf(graph, 'supplier:5', 'organization:3')).toEqual({ from: 'supplier:5', to: 'organization:3', certain: true }) + expect(edgeOf(graph, 'supplier:6', 'organization:3')).toEqual({ from: 'supplier:6', to: 'organization:3', certain: true }) + }) + + it('organizaciones distintas entre proveedores son dudosas', () => { + const graph = buildSupplierOrganizationGraph(twoSuppliers({ + first: { organization: { id: 3, name: 'Red' } }, + second: { organization: { id: 4, name: 'Cooperativa' } }, + })) + + expect(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(false) + expect(edgeOf(graph, 'supplier:6', 'organization:4').certain).toBe(false) }) it('incluye la imagen del producto y el detalle de la entidad', () => { - const graph = buildSupplierOrganizationGraph(provenance) + const graph = buildSupplierOrganizationGraph(singleSupplier) const product = graph.nodes.find(n => n.id === 'product:1') expect(product.image).toBe('http://localhost/media/panela.jpg') @@ -94,7 +143,7 @@ describe('buildSupplierOrganizationGraph', () => { const graph = buildSupplierOrganizationGraph(data) expect(graph.nodes.map(n => n.id)).not.toContain('organization:') - expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5' }]) + expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5', certain: true }]) }) it('no duplica nodos que se repiten entre productos', () => { @@ -109,15 +158,12 @@ describe('buildSupplierOrganizationGraph', () => { expect(graph.edges).toHaveLength(4) }) - it('ubica producto, proveedor y organización en columnas crecientes', () => { - const graph = buildSupplierOrganizationGraph(provenance) - + it('no incluye posiciones de layout', () => { + const graph = buildSupplierOrganizationGraph(singleSupplier) const product = graph.nodes.find(n => n.id === 'product:1') - const supplier = graph.nodes.find(n => n.id === 'supplier:5') - const organization = graph.nodes.find(n => n.id === 'organization:3') - expect(product.x).toBeLessThan(supplier.x) - expect(supplier.x).toBeLessThan(organization.x) + expect(product.x).toBeUndefined() + expect(product.y).toBeUndefined() }) it('devuelve un grafo vacío cuando no hay provenance', () => { @@ -129,19 +175,49 @@ describe('buildSupplierOrganizationGraph', () => { }) describe('buildTerritoryGraph', () => { - it('crea la cadena producto → proveedor → municipio → departamento → país', () => { - const graph = buildTerritoryGraph(provenance) + it('con un único proveedor toda la cadena es cierta', () => { + const graph = buildTerritoryGraph(singleSupplier) - const ids = graph.nodes.map(n => n.id) - expect(ids).toContain('product:1') - expect(ids).toContain('supplier:5') - expect(ids).toContain('municipality:7') - expect(ids).toContain('department:2') - expect(ids).toContain('country:1') + expect(edgeOf(graph, 'product:1', 'supplier:5')).toEqual({ from: 'product:1', to: 'supplier:5', certain: true }) + expect(edgeOf(graph, 'supplier:5', 'municipality:7')).toEqual({ from: 'supplier:5', to: 'municipality:7', certain: true }) + expect(edgeOf(graph, 'municipality:7', 'department:2')).toEqual({ from: 'municipality:7', to: 'department:2', certain: true }) + expect(edgeOf(graph, 'department:2', 'country:1')).toEqual({ from: 'department:2', to: 'country:1', certain: true }) + }) - expect(graph.edges).toContainEqual({ from: 'supplier:5', to: 'municipality:7' }) - expect(graph.edges).toContainEqual({ from: 'municipality:7', to: 'department:2' }) - expect(graph.edges).toContainEqual({ from: 'department:2', to: 'country:1' }) + it('cuando los proveedores comparten municipio, el territorio es cierto desde allí', () => { + const graph = buildTerritoryGraph(twoSuppliers({ + second: { municipality: { id: 7, name: 'La Mesa' }, department: { id: 2, name: 'Cundinamarca' } }, + })) + + expect(edgeOf(graph, 'product:1', 'junction:1').certain).toBe(true) + expect(edgeOf(graph, 'junction:1', 'supplier:5').certain).toBe(false) + expect(edgeOf(graph, 'supplier:5', 'municipality:7')).toEqual({ from: 'supplier:5', to: 'municipality:7', certain: true }) + expect(edgeOf(graph, 'supplier:6', 'municipality:7')).toEqual({ from: 'supplier:6', to: 'municipality:7', certain: true }) + expect(edgeOf(graph, 'municipality:7', 'department:2').certain).toBe(true) + expect(edgeOf(graph, 'department:2', 'country:1').certain).toBe(true) + }) + + it('municipios distintos pero mismo departamento: dudoso hasta el municipio y cierto desde el departamento', () => { + const graph = buildTerritoryGraph(twoSuppliers({ + second: { department: { id: 2, name: 'Cundinamarca' } }, + })) + + expect(edgeOf(graph, 'supplier:5', 'municipality:7').certain).toBe(false) + expect(edgeOf(graph, 'supplier:6', 'municipality:8').certain).toBe(false) + expect(edgeOf(graph, 'municipality:7', 'department:2')).toEqual({ from: 'municipality:7', to: 'department:2', certain: true }) + expect(edgeOf(graph, 'municipality:8', 'department:2')).toEqual({ from: 'municipality:8', to: 'department:2', certain: true }) + expect(edgeOf(graph, 'department:2', 'country:1').certain).toBe(true) + }) + + it('municipios y departamentos distintos mantienen la duda, y el país común es cierto', () => { + const graph = buildTerritoryGraph(twoSuppliers()) + + expect(edgeOf(graph, 'supplier:5', 'municipality:7').certain).toBe(false) + expect(edgeOf(graph, 'municipality:7', 'department:2').certain).toBe(false) + expect(edgeOf(graph, 'supplier:6', 'municipality:8').certain).toBe(false) + expect(edgeOf(graph, 'municipality:8', 'department:3').certain).toBe(false) + expect(edgeOf(graph, 'department:2', 'country:1')).toEqual({ from: 'department:2', to: 'country:1', certain: true }) + expect(edgeOf(graph, 'department:3', 'country:1')).toEqual({ from: 'department:3', to: 'country:1', certain: true }) }) it('no crea nodos de territorio cuando el proveedor no tiene municipio', () => { @@ -154,6 +230,6 @@ describe('buildTerritoryGraph', () => { const graph = buildTerritoryGraph(data) expect(graph.nodes.map(n => n.id)).toEqual(['product:1', 'supplier:5']) - expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5' }]) + expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5', certain: true }]) }) }) diff --git a/tests/unit/services/graph/graph-layout.spec.js b/tests/unit/services/graph/graph-layout.spec.js deleted file mode 100644 index a05afef..0000000 --- a/tests/unit/services/graph/graph-layout.spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { computeColumnLayout, toCytoscapeElements } from '@/services/graph/graph-layout' - -const nodes = [ - { id: 'product:1', kind: 'product', label: 'Panela' }, - { id: 'supplier:5', kind: 'supplier', label: 'La Mesa' }, - { id: 'organization:3', kind: 'organization', label: 'Red Solidaria' }, -] - -const edges = [ - { from: 'product:1', to: 'supplier:5' }, - { from: 'supplier:5', to: 'organization:3' }, -] - -function columnOf (node) { - return { product: 0, supplier: 1, organization: 2 }[node.kind] -} - -describe('computeColumnLayout', () => { - it('ubica cada nodo en su columna según la función columnOf', () => { - const result = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) - - const product = result.nodes.find(n => n.id === 'product:1') - const supplier = result.nodes.find(n => n.id === 'supplier:5') - const organization = result.nodes.find(n => n.id === 'organization:3') - - expect(product.x).toBeLessThan(supplier.x) - expect(supplier.x).toBeLessThan(organization.x) - expect(product.x).toBeCloseTo(20 + 0 * 200 + 100) - expect(supplier.x).toBeCloseTo(20 + 1 * 200 + 100) - expect(organization.x).toBeCloseTo(20 + 2 * 200 + 100) - }) - - it('separa verticalmente los nodos que comparten columna y conserva el orden', () => { - const many = [ - { id: 'supplier:1', kind: 'supplier', label: 'A' }, - { id: 'supplier:2', kind: 'supplier', label: 'B' }, - ] - const result = computeColumnLayout(many, [], { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) - - const a = result.nodes.find(n => n.id === 'supplier:1') - const b = result.nodes.find(n => n.id === 'supplier:2') - - expect(a.y).toBeCloseTo(20 + 80 / 2) - expect(b.y).toBeCloseTo(20 + 80 + 80 / 2) - expect(a.y).toBeLessThan(b.y) - }) - - it('calcula ancho y alto del lienzo según columnas y filas usadas', () => { - const result = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) - - expect(result.width).toBeCloseTo(20 * 2 + 3 * 200) - expect(result.height).toBeCloseTo(20 * 2 + 80) - }) - - it('respeta el orden de aparición de los nodos', () => { - const result = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) - - expect(result.nodes.map(n => n.id)).toEqual(['product:1', 'supplier:5', 'organization:3']) - }) -}) - -describe('toCytoscapeElements', () => { - it('convierte nodos y aristas al formato elements de cytoscape', () => { - const layout = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) - const elements = toCytoscapeElements(layout) - - const productNode = elements.find(e => e.data.id === 'product:1') - expect(productNode).toMatchObject({ - position: { x: productNode.position.x, y: productNode.position.y }, - classes: ['product'], - data: { id: 'product:1', kind: 'product', label: 'Panela' }, - }) - expect(typeof productNode.position.x).toBe('number') - expect(typeof productNode.position.y).toBe('number') - - const edge = elements.find(e => e.data.source === 'product:1') - expect(edge).toMatchObject({ data: { source: 'product:1', target: 'supplier:5' } }) - }) - - it('incluye datos extra del nodo en data.entity', () => { - const withEntity = [ - { id: 'product:1', kind: 'product', label: 'Panela', entity: { name: 'Panela', price: 3000 } }, - ] - const layout = computeColumnLayout(withEntity, [], { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) - const elements = toCytoscapeElements(layout) - - expect(elements[0].data.entity).toEqual({ name: 'Panela', price: 3000 }) - }) - - it('agrega la clase has-image a los nodos con imagen', () => { - const withImage = [ - { id: 'product:1', kind: 'product', label: 'Panela', image: 'http://x/panela.jpg' }, - ] - const layout = computeColumnLayout(withImage, [], { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) - const elements = toCytoscapeElements(layout) - - expect(elements[0].classes).toContain('has-image') - }) -})