#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

@@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
buildSupplierOrganizationGraph,
buildTerritoryGraph,
buildProvenanceGraph,
hasAnySupplier,
hasAnyTerritory,
} from '@/components/provenance/provenance-graph'
const singleSupplier = [
const ALL_KINDS = ['product', 'supplier', 'organization', 'municipality', 'department', 'country']
const singleRelation = [
{
product: {
id: 1,
@@ -15,39 +15,41 @@ const singleSupplier = [
},
suppliers: [
{
supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa', description: 'Cooperativa' },
organization: { id: 3, name: 'Red de Economía Solidaria', description: 'Red' },
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', code: 'CO' },
country: { id: 1, name: 'Colombia' },
},
],
},
]
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 twoSuppliers (overrides = {}) {
return [
{
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)
@@ -55,7 +57,7 @@ function edgeOf (graph, from, to) {
describe('hasAnySupplier', () => {
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', () => {
@@ -68,115 +70,82 @@ describe('hasAnySupplier', () => {
})
})
describe('hasAnyTerritory', () => {
it('es true cuando algún proveedor tiene territorio', () => {
expect(hasAnyTerritory(singleSupplier)).toBe(true)
})
describe('buildProvenanceGraph', () => {
it('por defecto solo grafica productos y proveedores', () => {
const graph = buildProvenanceGraph(singleRelation)
it('es false cuando todos los territorios vienen en null', () => {
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.nodes.map(n => n.kind)).toEqual(['product', 'supplier'])
expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5', certain: true }])
})
it('no duplica nodos ni aristas cuando se repiten entre productos', () => {
const data = [
{ 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)
it('con todos los niveles grafica la cadena completa y la organización', () => {
const graph = buildProvenanceGraph(singleRelation, ALL_KINDS)
expect(graph.nodes.filter(n => n.id === 'supplier:5')).toHaveLength(1)
expect(graph.nodes.filter(n => n.id === 'organization:3')).toHaveLength(1)
expect(graph.edges.filter(e => e.from === 'supplier:5' && e.to === 'organization:3')).toHaveLength(1)
expect(graph.edges).toHaveLength(3)
expect(edgeOf(graph, 'product:1', 'supplier:5')).toEqual({ from: 'product:1', to: 'supplier:5', certain: true })
expect(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(true)
expect(edgeOf(graph, 'supplier:5', 'municipality:7')).toEqual({ from: 'supplier:5', 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('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', () => {
@@ -184,98 +153,24 @@ describe('buildSupplierOrganizationGraph', () => {
{ 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' } }] },
]
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(edgeOf(graph, 'supplier:5', 'organization:3').certain).toBe(false)
})
it('no incluye posiciones de layout', () => {
const graph = buildSupplierOrganizationGraph(singleSupplier)
it('incluye la imagen y el detalle de la entidad del producto', () => {
const graph = buildProvenanceGraph(singleRelation)
const product = graph.nodes.find(n => n.id === 'product:1')
expect(product.x).toBeUndefined()
expect(product.y).toBeUndefined()
expect(product.image).toBe('http://localhost/media/panela.jpg')
expect(product.entity.kind).toBe('product')
})
it('devuelve un grafo vacío cuando no hay provenance', () => {
const graph = buildSupplierOrganizationGraph([])
const graph = buildProvenanceGraph([])
expect(graph.nodes).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 }])
})
})