feat/49-provenance #54

Merged
mono merged 22 commits from feat/49-provenance into main 2026-08-16 22:21:13 -05:00
4 changed files with 179 additions and 0 deletions
Showing only changes of commit efa666dcb3 - Show all commits

9
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,75 @@
<template>
<div
ref="containerRef"
class="cytoscape-chart"
:style="{ height: `${height}px` }"
/>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import cytoscape from 'cytoscape'
const props = defineProps({
elements: {
type: Array,
default: () => [],
},
styles: {
type: Array,
default: () => [],
},
layout: {
type: Object,
default: () => ({ name: 'preset' }),
},
height: {
type: Number,
default: 400,
},
})
const emit = defineEmits(['select'])
const containerRef = ref(null)
let cy = null
function runLayout () {
cy.layout(props.layout).run()
}
onMounted(() => {
cy = cytoscape({
container: containerRef.value,
elements: props.elements,
style: props.styles,
layout: props.layout,
})
cy.on('tap', 'node', event => {
emit('select', event.target.data())
})
})
watch(
() => props.elements,
elements => {
if (!cy) return
cy.elements().remove()
cy.add(elements)
runLayout()
},
{ deep: true }
)
onBeforeUnmount(() => {
if (cy) cy.destroy()
cy = null
})
</script>
<style scoped>
.cytoscape-chart {
width: 100%;
min-height: 200px;
}
</style>

View File

@@ -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))
})
})