#49 feat: grafico unificado de provenance con filtro por niveles y re-estabilizacion

This commit is contained in:
2026-08-16 03:10:57 -05:00
parent 5c91e66429
commit ad0bbb579d
14 changed files with 508 additions and 747 deletions

View File

@@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido:
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`. - Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
- **Genérico reutilizable:** - **Genérico reutilizable:**
- `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 - `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:<productId>` (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). El gráfico de territorio va solo hasta departamento (sin país, por ahora Colombia). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. Los charts (que muestran "próximamente estará disponible" cuando no hay relaciones), `ProvenanceDetailModal.vue` y `ProvenanceSection.vue` - **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) 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:<productId>` (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). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores) y columnas por nivel (x fijo por tipo; la física ordena la y); muestra "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` - **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/<id>/` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products/<id>/` - **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products/<id>/` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products/<id>/`
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, 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 }`

View File

@@ -27,6 +27,10 @@
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
minVerticalSpacing: {
type: Number,
default: null,
},
}) })
const emit = defineEmits(['select']) const emit = defineEmits(['select'])
@@ -87,7 +91,28 @@
network.setData({ nodes: nodesDataSet, edges: new DataSet(edges) }) network.setData({ nodes: nodesDataSet, edges: new DataSet(edges) })
} }
onMounted(() => { function applyMinVerticalSpacing (minSpacing) {
if (!minSpacing || minSpacing <= 0) return
const columns = new Map()
for (const [id, position] of Object.entries(network.getPositions())) {
const x = Math.round(position.x)
if (!columns.has(x)) columns.set(x, [])
columns.get(x).push({ id, x, y: position.y })
}
for (const column of columns.values()) {
column.sort((a, b) => a.y - b.y)
let previousY = null
for (const item of column) {
if (previousY !== null && item.y - previousY < minSpacing) {
network.moveNode(item.id, item.x, previousY + minSpacing)
}
previousY = network.getPositions()[item.id].y
}
}
}
function setupNetwork () {
if (network) network.destroy()
network = new Network(containerRef.value, { nodes: [], edges: [] }, mergeOptions()) network = new Network(containerRef.value, { nodes: [], edges: [] }, mergeOptions())
render(props.nodes, props.edges) render(props.nodes, props.edges)
@@ -100,15 +125,18 @@
network.once('stabilizationIterationsDone', () => { network.once('stabilizationIterationsDone', () => {
network.setOptions({ physics: { enabled: false } }) network.setOptions({ physics: { enabled: false } })
applyMinVerticalSpacing(props.minVerticalSpacing)
network.fit() network.fit()
}) })
}) }
onMounted(setupNetwork)
watch( watch(
() => [props.nodes, props.edges], () => [props.nodes, props.edges],
([nodes, edges]) => { () => {
if (!network) return if (!containerRef.value) return
render(nodes, edges) setupNetwork()
}, },
{ deep: true } { deep: true }
) )

View File

@@ -1,59 +0,0 @@
<template>
<div>
<v-alert
v-if="!hasRelations"
class="my-4"
type="info"
variant="tonal"
>
La información de proveedores y organizaciones de este pedido estará
disponible próximamente.
</v-alert>
<VisChart
v-else
data-test="supplier-org-chart"
:edges="visEdges"
:height="height"
:nodes="visNodes"
:options="chartOptions"
@select="onSelect"
/>
<ProvenanceDetailModal
:selected="selected"
:visible="modalVisible"
@update:visible="modalVisible = $event"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import VisChart from '@/components/graph/VisChart.vue'
import ProvenanceDetailModal from './ProvenanceDetailModal.vue'
import { buildSupplierOrganizationGraph, hasAnySupplier } from './provenance-graph'
import { chartOptions, toVisEdges, toVisNodes } from './provenance-vis'
const props = defineProps({
provenance: {
type: Array,
default: () => [],
},
})
const graph = computed(() => buildSupplierOrganizationGraph(props.provenance))
const hasRelations = computed(() => hasAnySupplier(props.provenance))
const visNodes = computed(() => toVisNodes(graph.value.nodes))
const visEdges = computed(() => toVisEdges(graph.value.edges))
const height = computed(() => Math.max(280, Math.min(560, graph.value.nodes.length * 72)))
const selected = ref(null)
const modalVisible = ref(false)
function onSelect (node) {
if (!node || node.kind === 'junction') return
selected.value = node
modalVisible.value = true
}
</script>

View File

@@ -1,59 +0,0 @@
<template>
<div>
<v-alert
v-if="!hasRelations"
class="my-4"
type="info"
variant="tonal"
>
La información de municipio, departamento y país de este pedido estará
disponible próximamente.
</v-alert>
<VisChart
v-else
data-test="territory-chart"
:edges="visEdges"
:height="height"
:nodes="visNodes"
:options="chartOptions"
@select="onSelect"
/>
<ProvenanceDetailModal
:selected="selected"
:visible="modalVisible"
@update:visible="modalVisible = $event"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import VisChart from '@/components/graph/VisChart.vue'
import ProvenanceDetailModal from './ProvenanceDetailModal.vue'
import { buildTerritoryGraph, hasAnyTerritory } from './provenance-graph'
import { chartOptions, toVisEdges, toVisNodes } from './provenance-vis'
const props = defineProps({
provenance: {
type: Array,
default: () => [],
},
})
const graph = computed(() => buildTerritoryGraph(props.provenance))
const hasRelations = computed(() => hasAnyTerritory(props.provenance))
const visNodes = computed(() => toVisNodes(graph.value.nodes))
const visEdges = computed(() => toVisEdges(graph.value.edges))
const height = computed(() => Math.max(280, Math.min(560, graph.value.nodes.length * 72)))
const selected = ref(null)
const modalVisible = ref(false)
function onSelect (node) {
if (!node || node.kind === 'junction') return
selected.value = node
modalVisible.value = true
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div>
<v-alert
v-if="!hasSuppliers"
class="my-4"
type="info"
variant="tonal"
>
La información de origen de los productos de este pedido estará disponible
próximamente.
</v-alert>
<template v-else>
<div class="d-flex flex-wrap align-center ga-4 mb-2">
<span class="text-body-2 font-weight-medium">Elementos a graficar:</span>
<v-checkbox
v-for="option in options"
:key="option.value"
v-model="selectedKinds"
density="compact"
:label="option.label"
:value="option.value"
/>
</div>
<v-alert
v-if="visNodes.length === 0"
class="my-2"
type="warning"
variant="tonal"
>
Selecciona al menos un elemento para graficar.
</v-alert>
<VisChart
v-if="visNodes.length > 0"
data-test="provenance-chart"
:edges="visEdges"
:height="height"
:min-vertical-spacing="minVerticalSpacing"
:nodes="visNodes"
:options="chartOptions"
@select="onSelect"
/>
</template>
<ProvenanceDetailModal
:selected="selected"
:visible="modalVisible"
@update:visible="modalVisible = $event"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import VisChart from '@/components/graph/VisChart.vue'
import ProvenanceDetailModal from './ProvenanceDetailModal.vue'
import { buildProvenanceGraph, hasAnySupplier } from './provenance-graph'
import { chartOptions, toVisEdges, toVisNodes } from './provenance-vis'
const props = defineProps({
provenance: {
type: Array,
default: () => [],
},
})
const options = [
{ value: 'product', label: 'Productos' },
{ value: 'supplier', label: 'Proveedores' },
{ value: 'organization', label: 'Organizaciones' },
{ value: 'municipality', label: 'Municipios' },
{ value: 'department', label: 'Departamentos' },
{ value: 'country', label: 'País' },
]
const selectedKinds = ref(['product', 'supplier'])
const hasSuppliers = computed(() => hasAnySupplier(props.provenance))
const graph = computed(() => buildProvenanceGraph(props.provenance, selectedKinds.value))
const visNodes = computed(() => toVisNodes(graph.value.nodes))
const visEdges = computed(() => toVisEdges(graph.value.edges))
const height = computed(() => Math.max(280, Math.min(700, graph.value.nodes.length * 64)))
const minVerticalSpacing = 2 * 24 + 22
const selected = ref(null)
const modalVisible = ref(false)
function onSelect (node) {
if (!node || node.kind === 'junction') return
selected.value = node
modalVisible.value = true
}
</script>

View File

@@ -6,19 +6,12 @@
Conoce quiénes producen los productos que compras y de qué territorios provienen. Conoce quiénes producen los productos que compras y de qué territorios provienen.
</p> </p>
<h4 class="text-subtitle-1 font-weight-medium mb-2">Proveedores y organizaciones</h4> <ProvenanceGraph :provenance="provenance" />
<ProductSupplierOrganizationChart :provenance="provenance" />
<v-divider class="my-4" />
<h4 class="text-subtitle-1 font-weight-medium mb-2">Departamento y municipio</h4>
<ProductTerritoryChart :provenance="provenance" />
</div> </div>
</template> </template>
<script setup> <script setup>
import ProductSupplierOrganizationChart from './ProductSupplierOrganizationChart.vue' import ProvenanceGraph from './ProvenanceGraph.vue'
import ProductTerritoryChart from './ProductTerritoryChart.vue'
defineProps({ defineProps({
provenance: { provenance: {

View File

@@ -8,11 +8,19 @@
* - Con varios proveedores para un producto se inserta un nodo de disyunción * - Con varios proveedores para un producto se inserta un nodo de disyunción
* (junction): sólida hasta él y discontinua hacia cada proveedor. * (junction): sólida hasta él y discontinua hacia cada proveedor.
* *
* `buildProvenanceGraph` permite elegir los niveles a graficar
* (product, supplier, organization, municipality, department, country);
* los niveles no solicitados se omiten conectando el nivel previo con el
* siguiente.
*
* El modelo devuelto es agnóstico de la herramienta de renderizado: * El modelo devuelto es agnóstico de la herramienta de renderizado:
* node: { id, kind, label, image, entity } * node: { id, kind, label, image, entity }
* edge: { from, to, certain } * edge: { from, to, certain }
*/ */
const DEFAULT_KINDS = ['product', 'supplier']
const TERRITORY_ORDER = ['municipality', 'department', 'country']
function makeCollector () { function makeCollector () {
const nodes = [] const nodes = []
const edges = [] const edges = []
@@ -57,66 +65,77 @@ function distinctValues (items) {
return [...seen.values()] 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) { function isCertain (values) {
return values.length <= 1 return values.length <= 1
} }
export function buildSupplierOrganizationGraph (provenance) { function wirePath (collector, path, certainties) {
for (let index = 0; index < path.length - 1; index++) {
const to = path[index + 1]
const kind = to.split(':')[0]
collector.addEdge(path[index], to, certainties[kind])
}
}
export function buildProvenanceGraph (provenance, kinds = DEFAULT_KINDS) {
const active = new Set(kinds)
const collector = makeCollector() const collector = makeCollector()
for (const entry of provenance || []) { for (const entry of provenance || []) {
if (!entry.product) continue if (!entry.product) continue
const product = collector.addNode('product', entry.product)
const relations = (entry.suppliers || []).filter(rel => rel.supplier) const relations = (entry.suppliers || []).filter(rel => rel.supplier)
if (relations.length === 0) continue if (relations.length === 0) continue
connectSuppliers(collector, product, relations)
const organizationCertain = isCertain(distinctValues(relations.map(rel => rel.organization))) const product = active.has('product') ? collector.addNode('product', entry.product) : null
const suppliersActive = active.has('supplier')
const certainties = {
municipality: isCertain(distinctValues(relations.map(rel => rel.municipality))),
department: isCertain(distinctValues(relations.map(rel => rel.department))),
country: isCertain(distinctValues(relations.map(rel => rel.country))),
organization: isCertain(distinctValues(relations.map(rel => rel.organization))),
}
for (const rel of relations) {
if (suppliersActive) collector.addNode('supplier', rel.supplier)
if (active.has('municipality') && rel.municipality) collector.addNode('municipality', rel.municipality)
if (active.has('department') && rel.department) collector.addNode('department', rel.department)
if (active.has('country') && rel.country) collector.addNode('country', rel.country)
if (active.has('organization') && rel.organization) collector.addNode('organization', rel.organization)
}
if (suppliersActive && product) {
if (relations.length === 1) {
collector.addEdge(product.id, `supplier:${relations[0].supplier.id}`, true)
} else {
const junction = collector.addNode('junction', { id: entry.product.id, name: '' })
collector.addEdge(product.id, junction.id, true)
for (const rel of relations) {
collector.addEdge(junction.id, `supplier:${rel.supplier.id}`, false)
}
}
}
for (const rel of relations) {
const path = []
if (suppliersActive) {
path.push(`supplier:${rel.supplier.id}`)
} else if (product) {
path.push(product.id)
}
for (const level of TERRITORY_ORDER) {
if (active.has(level) && rel[level]) path.push(`${level}:${rel[level].id}`)
}
wirePath(collector, path, certainties)
}
if (active.has('organization')) {
for (const rel of relations) { for (const rel of relations) {
if (!rel.organization) continue if (!rel.organization) continue
const organization = collector.addNode('organization', rel.organization) const source = suppliersActive ? `supplier:${rel.supplier.id}` : (product ? product.id : null)
collector.addEdge(`supplier:${rel.supplier.id}`, organization.id, organizationCertain) if (source) collector.addEdge(source, `organization:${rel.organization.id}`, certainties.organization)
} }
} }
return { nodes: collector.nodes, edges: collector.edges }
}
export function buildTerritoryGraph (provenance) {
const collector = makeCollector()
for (const entry of provenance || []) {
if (!entry.product) continue
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)))
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)
}
} }
return { nodes: collector.nodes, edges: collector.edges } return { nodes: collector.nodes, edges: collector.edges }
} }
@@ -124,11 +143,3 @@ export function buildTerritoryGraph (provenance) {
export function hasAnySupplier (provenance) { export function hasAnySupplier (provenance) {
return (provenance || []).some(entry => (entry.suppliers || []).length > 0) return (provenance || []).some(entry => (entry.suppliers || []).length > 0)
} }
export function hasAnyTerritory (provenance) {
return (provenance || []).some(entry =>
(entry.suppliers || []).some(rel =>
rel.municipality || rel.department
)
)
}

View File

@@ -17,11 +17,12 @@ const COLORS = {
const PRODUCT_SPACING = 110 const PRODUCT_SPACING = 110
const COLUMN_X = { const COLUMN_X = {
product: -240, product: -240,
junction: -120, junction: -160,
supplier: 0, supplier: -80,
organization: 240, organization: 80,
municipality: 240, municipality: 240,
department: 480, department: 400,
country: 560,
} }
export function toVisNodes (nodes) { export function toVisNodes (nodes) {

View File

@@ -10,6 +10,8 @@ const vis = vi.hoisted(() => {
setOptions: vi.fn(), setOptions: vi.fn(),
fit: vi.fn(), fit: vi.fn(),
destroy: vi.fn(), destroy: vi.fn(),
getPositions: vi.fn(() => ({})),
moveNode: vi.fn(),
} }
const dataSetInstance = { const dataSetInstance = {
get: vi.fn(), get: vi.fn(),
@@ -96,12 +98,46 @@ describe('VisChart', () => {
expect(vis.networkInstance.fit).toHaveBeenCalled() expect(vis.networkInstance.fit).toHaveBeenCalled()
}) })
it('actualiza la red cuando cambian los nodos o aristas', async () => { it('separa verticalmente los nodos de una misma columna tras estabilizar', () => {
vis.networkInstance.getPositions.mockReturnValue({
'supplier:5': { x: 0, y: 100 },
'supplier:6': { x: 0, y: 112 },
'municipality:7': { x: 240, y: 100 },
'product:1': { x: -240, y: 60 },
})
mount(VisChart, { props: { nodes: [], edges: [], minVerticalSpacing: 70 } })
const onStabilized = handlerFor(vis.networkInstance.once, 'stabilizationIterationsDone')
onStabilized()
expect(vis.networkInstance.moveNode).toHaveBeenCalledWith('supplier:6', 0, 170)
expect(vis.networkInstance.moveNode).not.toHaveBeenCalledWith('supplier:5', 0, expect.anything())
expect(vis.networkInstance.moveNode).not.toHaveBeenCalledWith('municipality:7', 240, expect.anything())
expect(vis.networkInstance.moveNode).not.toHaveBeenCalledWith('product:1', -240, expect.anything())
})
it('no separa nodos cuando no se indica espaciado vertical mínimo', () => {
vis.networkInstance.getPositions.mockReturnValue({
'supplier:5': { x: 0, y: 100 },
'supplier:6': { x: 0, y: 112 },
})
mount(VisChart, { props: { nodes: [], edges: [] } })
const onStabilized = handlerFor(vis.networkInstance.once, 'stabilizationIterationsDone')
onStabilized()
expect(vis.networkInstance.moveNode).not.toHaveBeenCalled()
})
it('vuelve a estabilizar el grafo cuando cambian los nodos o aristas', async () => {
const wrapper = mount(VisChart, { props: { nodes: [{ id: 'product:1' }], edges: [] } }) const wrapper = mount(VisChart, { props: { nodes: [{ id: 'product:1' }], edges: [] } })
expect(vis.networkInstance.setData).toHaveBeenCalledTimes(1) expect(vis.Network).toHaveBeenCalledTimes(1)
expect(vis.networkInstance.destroy).not.toHaveBeenCalled()
await wrapper.setProps({ nodes: [{ id: 'product:2' }], edges: [{ from: 'product:2', to: 'supplier:5' }] }) await wrapper.setProps({ nodes: [{ id: 'product:2' }], edges: [{ from: 'product:2', to: 'supplier:5' }] })
expect(vis.networkInstance.destroy).toHaveBeenCalled()
expect(vis.Network).toHaveBeenCalledTimes(2)
expect(vis.networkInstance.setData).toHaveBeenCalledTimes(2) expect(vis.networkInstance.setData).toHaveBeenCalledTimes(2)
}) })

View File

@@ -1,182 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue'
import ProductSupplierOrganizationChart from '@/components/provenance/ProductSupplierOrganizationChart.vue'
import vuetify from '@/plugins/vuetify'
vi.mock('@/components/graph/VisChart.vue', () => ({
default: {
name: 'VisChart',
template: '<div class="vis-chart-stub" />',
props: ['nodes', 'edges', 'height', 'options'],
},
}))
const provenance = [
{
product: {
id: 1,
name: 'Panela regional por Kg',
catalogue_images: ['http://localhost/media/panela.jpg'],
},
suppliers: [
{
supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa', description: 'Cooperativa' },
organization: { id: 3, name: 'Red de Economía Solidaria', description: 'Red' },
municipality: null,
department: null,
country: null,
},
],
},
]
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 },
global: { plugins: [vuetify] },
})
}
function visChart (wrapper) {
return wrapper.findComponent({ name: 'VisChart' })
}
describe('ProductSupplierOrganizationChart', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renderiza el gráfico con nodos de productos, proveedores y organizaciones', () => {
const wrapper = mountChart()
const chart = visChart(wrapper)
expect(chart.exists()).toBe(true)
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('incluye la etiqueta del nodo y la imagen circular para quien tiene foto', () => {
const wrapper = mountChart()
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', () => {
const wrapper = mountChart()
expect(wrapper.text()).not.toContain('próximamente')
})
it('muestra mensaje de próximamente cuando ningún producto tiene proveedores', () => {
const wrapper = mountChart({
provenance: [{ product: { id: 1, name: 'Panela' }, suppliers: [] }],
})
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()
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)
})
it('alinea los productos en una columna vertical fija y centrada', () => {
const provenance = [
{ product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' } }] },
{ product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 6, name: 'B' } }] },
{ product: { id: 3, name: 'Queso' }, suppliers: [{ supplier: { id: 7, name: 'C' } }] },
]
const wrapper = mountChart({ provenance })
const products = visChart(wrapper).props('nodes').filter(node => node.kind === 'product')
expect(products).toHaveLength(3)
expect(new Set(products.map(node => node.x)).size).toBe(1)
expect(products.map(node => node.y)).toEqual([-110, 0, 110])
products.forEach(node => {
expect(node.fixed).toEqual({ x: true, y: true })
})
})
it('alinea proveedores y organizaciones en columnas por nivel', () => {
const wrapper = mountChart()
const supplier = visChart(wrapper).props('nodes').find(node => node.id === 'supplier:5')
expect(supplier.x).toBe(0)
expect(supplier.fixed).toEqual({ x: true, y: false })
expect(supplier.y).toBeUndefined()
const organization = visChart(wrapper).props('nodes').find(node => node.id === 'organization:3')
expect(organization.x).toBe(240)
expect(organization.fixed).toEqual({ x: true, y: false })
})
it('el punto de disyunción queda a la misma altura que su producto', () => {
const wrapper = mountChart({ provenance: provenanceWithTwoSuppliers })
const junction = visChart(wrapper).props('nodes').find(node => node.id === 'junction:1')
const product = visChart(wrapper).props('nodes').find(node => node.id === 'product:1')
expect(junction.x).toBe(-120)
expect(junction.y).toBe(product.y)
expect(junction.fixed).toEqual({ x: true, y: true })
})
})

View File

@@ -1,146 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue'
import ProductTerritoryChart from '@/components/provenance/ProductTerritoryChart.vue'
import vuetify from '@/plugins/vuetify'
vi.mock('@/components/graph/VisChart.vue', () => ({
default: {
name: 'VisChart',
template: '<div class="vis-chart-stub" />',
props: ['nodes', 'edges', 'height', 'options'],
},
}))
const provenance = [
{
product: { id: 1, name: 'Panela regional por Kg' },
suppliers: [
{
supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa' },
organization: null,
municipality: { id: 7, name: 'La Mesa' },
department: { id: 2, name: 'Cundinamarca' },
country: { id: 1, name: 'Colombia', code: 'CO' },
},
],
},
]
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 },
global: { plugins: [vuetify] },
})
}
function visChart (wrapper) {
return wrapper.findComponent({ name: 'VisChart' })
}
describe('ProductTerritoryChart', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renderiza el gráfico con municipio y departamento, sin país', () => {
const wrapper = mountChart()
const chart = visChart(wrapper)
expect(chart.exists()).toBe(true)
const nodeIds = chart.props('nodes').map(node => node.id)
expect(nodeIds).toContain('product:1')
expect(nodeIds).toContain('supplier:5')
expect(nodeIds).toContain('municipality:7')
expect(nodeIds).toContain('department:2')
expect(nodeIds).not.toContain('country:1')
})
it('incluye la etiqueta del nodo y el color según el tipo', () => {
const wrapper = mountChart()
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)
})
it('con varios productos del mismo proveedor solo traza una línea al municipio', () => {
const provenanceWithRepeatedSupplier = [
{ product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] },
{ product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] },
{ product: { id: 3, name: 'Queso' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] },
]
const wrapper = mountChart({ provenance: provenanceWithRepeatedSupplier })
const chart = visChart(wrapper)
const edges = chart.props('edges')
expect(edges.filter(edge => edge.from === 'supplier:5' && edge.to === 'municipality:7')).toHaveLength(1)
expect(chart.props('nodes').filter(node => node.id === 'municipality:7')).toHaveLength(1)
})
it('muestra mensaje de próximamente cuando ningún proveedor tiene territorio', () => {
const wrapper = mountChart({
provenance: [
{
product: { id: 1, name: 'Panela' },
suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: null, department: null, country: null }],
},
],
})
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()
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')
})
it('alinea municipios y departamentos en columnas por nivel', () => {
const wrapper = mountChart()
const nodes = visChart(wrapper).props('nodes')
expect(nodes.find(node => node.id === 'supplier:5').x).toBe(0)
expect(nodes.find(node => node.id === 'municipality:7').x).toBe(240)
expect(nodes.find(node => node.id === 'department:2').x).toBe(480)
})
})

View File

@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue'
import ProvenanceGraph from '@/components/provenance/ProvenanceGraph.vue'
import vuetify from '@/plugins/vuetify'
vi.mock('@/components/graph/VisChart.vue', () => ({
default: {
name: 'VisChart',
template: '<div class="vis-chart-stub" />',
props: ['nodes', 'edges', 'height', 'options', 'minVerticalSpacing'],
},
}))
const provenance = [
{
product: {
id: 1,
name: 'Panela regional por Kg',
catalogue_images: ['http://localhost/media/panela.jpg'],
},
suppliers: [
{
supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa' },
organization: { id: 3, name: 'Red de Economía Solidaria' },
municipality: { id: 7, name: 'La Mesa' },
department: { id: 2, name: 'Cundinamarca' },
country: { id: 1, name: 'Colombia' },
},
],
},
]
function mountGraph (props = {}) {
return mount(ProvenanceGraph, {
props: { provenance, ...props },
global: { plugins: [vuetify] },
})
}
function visChart (wrapper) {
return wrapper.findComponent({ name: 'VisChart' })
}
function checkbox (wrapper, label) {
return wrapper.findAllComponents({ name: 'VCheckbox' }).find(cb => cb.props('label') === label)
}
async function setKinds (wrapper, kinds) {
checkbox(wrapper, 'Productos').vm.$emit('update:modelValue', kinds)
await nextTick()
}
describe('ProvenanceGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('por defecto grafica solo productos y proveedores', () => {
const wrapper = mountGraph()
const nodeKinds = visChart(wrapper).props('nodes').map(node => node.kind)
expect(nodeKinds).toEqual(['product', 'supplier'])
const edges = visChart(wrapper).props('edges')
expect(edges.find(edge => edge.from === 'product:1' && edge.to === 'supplier:5').dashes).toBe(false)
})
it('pide separación vertical mínima equivalente al círculo más una línea de label', () => {
const wrapper = mountGraph()
expect(visChart(wrapper).props('minVerticalSpacing')).toBe(70)
})
it('muestra un checkbox por cada nivel con productos y proveedores activos', () => {
const wrapper = mountGraph()
const labels = wrapper.findAllComponents({ name: 'VCheckbox' }).map(cb => cb.props('label'))
expect(labels).toEqual(['Productos', 'Proveedores', 'Organizaciones', 'Municipios', 'Departamentos', 'País'])
})
it('al activar municipios agrega nodos de municipio y la arista proveedor→municipio', async () => {
const wrapper = mountGraph()
await setKinds(wrapper, ['product', 'supplier', 'municipality'])
const nodeIds = visChart(wrapper).props('nodes').map(node => node.id)
expect(nodeIds).toContain('municipality:7')
const edges = visChart(wrapper).props('edges')
expect(edges.find(edge => edge.from === 'supplier:5' && edge.to === 'municipality:7').dashes).toBe(false)
})
it('con país activo crea el nodo país', async () => {
const wrapper = mountGraph()
await setKinds(wrapper, ['product', 'supplier', 'country'])
expect(visChart(wrapper).props('nodes').map(node => node.id)).toContain('country:1')
})
it('al desactivar productos no se crean nodos de producto', async () => {
const wrapper = mountGraph()
await setKinds(wrapper, ['supplier', 'municipality', 'department'])
const nodeKinds = visChart(wrapper).props('nodes').map(node => node.kind)
expect(nodeKinds).not.toContain('product')
expect(nodeKinds).not.toContain('junction')
})
it('avisa cuando no queda ningún nivel seleccionado', async () => {
const wrapper = mountGraph()
await setKinds(wrapper, [])
expect(visChart(wrapper).exists()).toBe(false)
expect(wrapper.text()).toContain('Selecciona al menos un elemento')
})
it('muestra mensaje de próximamente cuando ningún producto tiene proveedores', () => {
const wrapper = mountGraph({
provenance: [{ product: { id: 1, name: 'Panela' }, suppliers: [] }],
})
expect(visChart(wrapper).exists()).toBe(false)
expect(wrapper.text()).toContain('próximamente')
})
it('abre el modal de detalle al seleccionar un proveedor', async () => {
const wrapper = mountGraph()
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 provenanceWithTwoSuppliers = [
{
product: { id: 1, name: 'Panela' },
suppliers: [
{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'La Mesa' } },
{ supplier: { id: 6, name: 'B' }, municipality: { id: 8, name: 'San Antonio' } },
],
},
]
const wrapper = mountGraph({ provenance: provenanceWithTwoSuppliers })
await visChart(wrapper).vm.$emit('select', { id: 'junction:1', kind: 'junction', label: '' })
expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(false)
})
})

View File

@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import ProductSupplierOrganizationChart from '@/components/provenance/ProductSupplierOrganizationChart.vue' import ProvenanceGraph from '@/components/provenance/ProvenanceGraph.vue'
import ProductTerritoryChart from '@/components/provenance/ProductTerritoryChart.vue'
import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue' import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue'
import vuetify from '@/plugins/vuetify' import vuetify from '@/plugins/vuetify'
@@ -39,30 +38,25 @@ describe('ProvenanceSection', () => {
it('no renderiza nada cuando no hay provenance', () => { it('no renderiza nada cuando no hay provenance', () => {
const wrapper = mountSection({ provenance: null }) const wrapper = mountSection({ provenance: null })
expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(false) expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(false)
}) })
it('no renderiza nada cuando provenance es una lista vacía', () => { it('no renderiza nada cuando provenance es una lista vacía', () => {
const wrapper = mountSection({ provenance: [] }) const wrapper = mountSection({ provenance: [] })
expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(false) expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(false)
}) })
it('muestra los dos gráficos con sus títulos cuando hay provenance', () => { it('muestra el gráfico unificado con su título cuando hay provenance', () => {
const wrapper = mountSection() const wrapper = mountSection()
expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(true) expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(true) expect(wrapper.text()).toContain('Origen e historia de los productos')
expect(wrapper.text()).toContain('Proveedores y organizaciones')
expect(wrapper.text()).toContain('Departamento y municipio')
}) })
it('pasa el provenance a ambos gráficos', () => { it('pasa el provenance al gráfico', () => {
const wrapper = mountSection() const wrapper = mountSection()
expect(wrapper.findComponent(ProductSupplierOrganizationChart).props('provenance')).toStrictEqual(provenance) expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance)
expect(wrapper.findComponent(ProductTerritoryChart).props('provenance')).toStrictEqual(provenance)
}) })
}) })

View File

@@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
buildSupplierOrganizationGraph, buildProvenanceGraph,
buildTerritoryGraph,
hasAnySupplier, hasAnySupplier,
hasAnyTerritory,
} from '@/components/provenance/provenance-graph' } from '@/components/provenance/provenance-graph'
const singleSupplier = [ const ALL_KINDS = ['product', 'supplier', 'organization', 'municipality', 'department', 'country']
const singleRelation = [
{ {
product: { product: {
id: 1, id: 1,
@@ -15,17 +15,18 @@ const singleSupplier = [
}, },
suppliers: [ suppliers: [
{ {
supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa', description: 'Cooperativa' }, supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa' },
organization: { id: 3, name: 'Red de Economía Solidaria', description: 'Red' }, organization: { id: 3, name: 'Red de Economía Solidaria' },
municipality: { id: 7, name: 'La Mesa' }, municipality: { id: 7, name: 'La Mesa' },
department: { id: 2, name: 'Cundinamarca' }, department: { id: 2, name: 'Cundinamarca' },
country: { id: 1, name: 'Colombia', code: 'CO' }, country: { id: 1, name: 'Colombia' },
}, },
], ],
}, },
] ]
const twoSuppliers = (overrides = {}) => [ function twoSuppliers (overrides = {}) {
return [
{ {
product: { id: 1, name: 'Panela' }, product: { id: 1, name: 'Panela' },
suppliers: [ suppliers: [
@@ -48,6 +49,7 @@ const twoSuppliers = (overrides = {}) => [
], ],
}, },
] ]
}
function edgeOf (graph, from, to) { function edgeOf (graph, from, to) {
return graph.edges.find(edge => edge.from === from && edge.to === to) return graph.edges.find(edge => edge.from === from && edge.to === to)
@@ -55,7 +57,7 @@ function edgeOf (graph, from, to) {
describe('hasAnySupplier', () => { describe('hasAnySupplier', () => {
it('es true cuando algún producto tiene proveedores', () => { it('es true cuando algún producto tiene proveedores', () => {
expect(hasAnySupplier(singleSupplier)).toBe(true) expect(hasAnySupplier(singleRelation)).toBe(true)
}) })
it('es false cuando todos los productos están sin proveedores', () => { it('es false cuando todos los productos están sin proveedores', () => {
@@ -68,115 +70,82 @@ describe('hasAnySupplier', () => {
}) })
}) })
describe('hasAnyTerritory', () => { describe('buildProvenanceGraph', () => {
it('es true cuando algún proveedor tiene territorio', () => { it('por defecto solo grafica productos y proveedores', () => {
expect(hasAnyTerritory(singleSupplier)).toBe(true) const graph = buildProvenanceGraph(singleRelation)
})
it('es false cuando todos los territorios vienen en null', () => { expect(graph.nodes.map(n => n.kind)).toEqual(['product', 'supplier'])
const noTerritory = [
{
product: { id: 1, name: 'Panela' },
suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: null, municipality: null, department: null, country: null }],
},
]
expect(hasAnyTerritory(noTerritory)).toBe(false)
})
it('es false cuando no hay suppliers', () => {
const noSuppliers = [{ product: { id: 1, name: 'Panela' }, suppliers: [] }]
expect(hasAnyTerritory(noSuppliers)).toBe(false)
})
it('es true cuando hay municipio aunque el país venga null', () => {
const data = [
{
product: { id: 1, name: 'Panela' },
suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: null, country: null }],
},
]
expect(hasAnyTerritory(data)).toBe(true)
})
it('es false cuando solo hay país', () => {
const data = [
{
product: { id: 1, name: 'Panela' },
suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: null, department: null, country: { id: 1, name: 'Colombia' } }],
},
]
expect(hasAnyTerritory(data)).toBe(false)
})
})
describe('buildSupplierOrganizationGraph', () => {
it('con un único proveedor conecta con arista cierta y sin junction', () => {
const graph = buildSupplierOrganizationGraph(singleSupplier)
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 })
})
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(singleSupplier)
const product = graph.nodes.find(n => n.id === 'product:1')
expect(product.image).toBe('http://localhost/media/panela.jpg')
expect(product.label).toBe('Panela regional por Kg')
expect(product.entity.kind).toBe('product')
expect(product.entity.name).toBe('Panela regional por Kg')
})
it('omite el nodo de organización cuando la relación es null', () => {
const data = [
{
product: { id: 1, name: 'Panela' },
suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: null }],
},
]
const graph = buildSupplierOrganizationGraph(data)
expect(graph.nodes.map(n => n.id)).not.toContain('organization:')
expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5', certain: true }]) expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5', certain: true }])
}) })
it('no duplica nodos ni aristas cuando se repiten entre productos', () => { it('con todos los niveles grafica la cadena completa y la organización', () => {
const data = [ const graph = buildProvenanceGraph(singleRelation, ALL_KINDS)
{ product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' } }] },
{ product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' } }] },
]
const graph = buildSupplierOrganizationGraph(data)
expect(graph.nodes.filter(n => n.id === 'supplier:5')).toHaveLength(1) expect(edgeOf(graph, 'product:1', 'supplier:5')).toEqual({ from: 'product:1', to: 'supplier:5', certain: true })
expect(graph.nodes.filter(n => n.id === 'organization:3')).toHaveLength(1) expect(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(true)
expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'organization:3')).toHaveLength(1) expect(edgeOf(graph, 'supplier:5', 'municipality:7')).toEqual({ from: 'supplier:5', to: 'municipality:7', certain: true })
expect(graph.edges).toHaveLength(3) expect(edgeOf(graph, 'municipality:7', 'department:2').certain).toBe(true)
expect(edgeOf(graph, 'department:2', 'country:1').certain).toBe(true)
})
it('con varios proveedores crea el punto de disyunción y la duda se propaga', () => {
const graph = buildProvenanceGraph(twoSuppliers(), ALL_KINDS)
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').certain).toBe(false)
expect(edgeOf(graph, 'junction:1', 'supplier:6').certain).toBe(false)
expect(edgeOf(graph, 'supplier:5', 'municipality:7').certain).toBe(false)
expect(edgeOf(graph, 'municipality:7', 'department:2').certain).toBe(false)
expect(edgeOf(graph, 'department:2', 'country:1').certain).toBe(true)
expect(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(true)
})
it('omite los niveles no solicitados conectando el nivel previo con el siguiente', () => {
const graph = buildProvenanceGraph(singleRelation, ['product', 'supplier', 'department'])
expect(graph.nodes.map(n => n.kind)).toEqual(['product', 'supplier', 'department'])
expect(edgeOf(graph, 'supplier:5', 'department:2')).toEqual({ from: 'supplier:5', to: 'department:2', certain: true })
expect(edgeOf(graph, 'supplier:5', 'municipality:7')).toBeUndefined()
})
it('sin proveedores activos conecta el producto con el siguiente nivel', () => {
const graph = buildProvenanceGraph(singleRelation, ['product', 'municipality', 'department'])
expect(edgeOf(graph, 'product:1', 'municipality:7')).toEqual({ from: 'product:1', to: 'municipality:7', certain: true })
expect(edgeOf(graph, 'municipality:7', 'department:2').certain).toBe(true)
})
it('sin productos activos los proveedores quedan como raíces sin junction', () => {
const graph = buildProvenanceGraph(twoSuppliers(), ['supplier', 'municipality'])
expect(graph.nodes.some(n => n.kind === 'product')).toBe(false)
expect(graph.nodes.some(n => n.kind === 'junction')).toBe(false)
expect(edgeOf(graph, 'supplier:5', 'municipality:7').certain).toBe(false)
})
it('con país activo crea el nodo país aunque se omitan niveles intermedios', () => {
const graph = buildProvenanceGraph(singleRelation, ['product', 'supplier', 'country'])
expect(graph.nodes.map(n => n.id)).toContain('country:1')
expect(edgeOf(graph, 'supplier:5', 'country:1').certain).toBe(true)
})
it('no crea nodos de país si no se solicita', () => {
const graph = buildProvenanceGraph(singleRelation, ['product', 'supplier', 'municipality'])
expect(graph.nodes.some(n => n.kind === 'country')).toBe(false)
})
it('no duplica aristas cuando varios productos comparten proveedor y municipio', () => {
const data = [
{ product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'La Mesa' }, department: { id: 2, name: 'Cundinamarca' } }] },
{ product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'La Mesa' }, department: { id: 2, name: 'Cundinamarca' } }] },
]
const graph = buildProvenanceGraph(data, ['product', 'supplier', 'municipality'])
expect(graph.nodes.filter(n => n.id === 'municipality:7')).toHaveLength(1)
expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'municipality:7')).toHaveLength(1)
}) })
it('si el mismo par de nodos repite con distinta certeza, gana la duda', () => { it('si el mismo par de nodos repite con distinta certeza, gana la duda', () => {
@@ -184,98 +153,24 @@ describe('buildSupplierOrganizationGraph', () => {
{ product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, organization: { id: 3, name: 'Red' } }] }, { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, organization: { id: 3, name: 'Red' } }] },
{ product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'A' }, organization: { id: 3, name: 'Red' } }, { supplier: { id: 6, name: 'B' }, organization: { id: 4, name: 'Coop' } }] }, { product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'A' }, organization: { id: 3, name: 'Red' } }, { supplier: { id: 6, name: 'B' }, organization: { id: 4, name: 'Coop' } }] },
] ]
const graph = buildSupplierOrganizationGraph(data) const graph = buildProvenanceGraph(data, ['product', 'supplier', 'organization'])
expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'organization:3')).toHaveLength(1) expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'organization:3')).toHaveLength(1)
expect(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(false) expect(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(false)
}) })
it('no incluye posiciones de layout', () => { it('incluye la imagen y el detalle de la entidad del producto', () => {
const graph = buildSupplierOrganizationGraph(singleSupplier) const graph = buildProvenanceGraph(singleRelation)
const product = graph.nodes.find(n => n.id === 'product:1') const product = graph.nodes.find(n => n.id === 'product:1')
expect(product.x).toBeUndefined() expect(product.image).toBe('http://localhost/media/panela.jpg')
expect(product.y).toBeUndefined() expect(product.entity.kind).toBe('product')
}) })
it('devuelve un grafo vacío cuando no hay provenance', () => { it('devuelve un grafo vacío cuando no hay provenance', () => {
const graph = buildSupplierOrganizationGraph([]) const graph = buildProvenanceGraph([])
expect(graph.nodes).toEqual([]) expect(graph.nodes).toEqual([])
expect(graph.edges).toEqual([]) expect(graph.edges).toEqual([])
}) })
}) })
describe('buildTerritoryGraph', () => {
it('con un único proveedor toda la cadena es cierta', () => {
const graph = buildTerritoryGraph(singleSupplier)
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 })
})
it('no incluye nodos ni aristas de país', () => {
const graph = buildTerritoryGraph(singleSupplier)
expect(graph.nodes.map(n => n.id)).not.toContain('country:1')
expect(graph.edges.some(edge => edge.from.startsWith('country:') || edge.to.startsWith('country:'))).toBe(false)
})
it('con varios productos del mismo proveedor solo traza una línea hacia el municipio', () => {
const data = [
{ product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] },
{ product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] },
{ product: { id: 3, name: 'Queso' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara' }, department: { id: 2, name: 'Antioquia' } }] },
]
const graph = buildTerritoryGraph(data)
expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'municipality:7')).toHaveLength(1)
expect(graph.nodes.filter(n => n.id === 'municipality:7')).toHaveLength(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)
})
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 })
})
it('municipios y departamentos distintos mantienen la duda', () => {
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)
})
it('no crea nodos de territorio cuando el proveedor no tiene municipio', () => {
const data = [
{
product: { id: 1, name: 'Panela' },
suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: null, department: null, country: null }],
},
]
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', certain: true }])
})
})