diff --git a/package-lock.json b/package-lock.json
index 2312abe..f5a22c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"@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",
@@ -2752,6 +2753,14 @@
"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",
diff --git a/package.json b/package.json
index 8818240..a6a6774 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"@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",
diff --git a/src/components/graph/CytoscapeChart.vue b/src/components/graph/CytoscapeChart.vue
new file mode 100644
index 0000000..ced6cc4
--- /dev/null
+++ b/src/components/graph/CytoscapeChart.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
diff --git a/tests/unit/components/graph/CytoscapeChart.spec.js b/tests/unit/components/graph/CytoscapeChart.spec.js
new file mode 100644
index 0000000..81cca6e
--- /dev/null
+++ b/tests/unit/components/graph/CytoscapeChart.spec.js
@@ -0,0 +1,94 @@
+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),
+}))
+
+import cytoscape from 'cytoscape'
+
+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))
+ })
+})