diff --git a/src/components/PublicOrderSummary.vue b/src/components/PublicOrderSummary.vue index 484b2a8..c45f4e2 100644 --- a/src/components/PublicOrderSummary.vue +++ b/src/components/PublicOrderSummary.vue @@ -40,6 +40,10 @@ + @@ -51,6 +55,7 @@ import OrderLines from '@/components/order/OrderLines.vue' import OrderPayment from '@/components/order/OrderPayment.vue' import OrderTotal from '@/components/order/OrderTotal.vue' + import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue' defineProps({ purchase: { diff --git a/src/components/provenance/ProductSupplierOrganizationChart.vue b/src/components/provenance/ProductSupplierOrganizationChart.vue new file mode 100644 index 0000000..5e2d6a4 --- /dev/null +++ b/src/components/provenance/ProductSupplierOrganizationChart.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/components/provenance/ProductTerritoryChart.vue b/src/components/provenance/ProductTerritoryChart.vue new file mode 100644 index 0000000..d77cb48 --- /dev/null +++ b/src/components/provenance/ProductTerritoryChart.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/components/provenance/ProvenanceDetailModal.vue b/src/components/provenance/ProvenanceDetailModal.vue new file mode 100644 index 0000000..f013836 --- /dev/null +++ b/src/components/provenance/ProvenanceDetailModal.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/components/provenance/ProvenanceSection.vue b/src/components/provenance/ProvenanceSection.vue new file mode 100644 index 0000000..efae78d --- /dev/null +++ b/src/components/provenance/ProvenanceSection.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/services/graph/graph-layout.js b/src/services/graph/graph-layout.js index f1eb3e7..ef635ef 100644 --- a/src/services/graph/graph-layout.js +++ b/src/services/graph/graph-layout.js @@ -51,17 +51,20 @@ export function computeColumnLayout (nodes, edges, options = {}) { export function toCytoscapeElements ({ nodes, edges }) { return [ - ...nodes.map(node => ({ - data: { - id: node.id, - kind: node.kind, - label: node.label, - image: node.image || null, - entity: node.entity || null, - }, - classes: [node.kind], - position: { x: node.x, y: node.y }, - })), + ...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}`, diff --git a/tests/unit/components/PublicOrderSummary.spec.js b/tests/unit/components/PublicOrderSummary.spec.js index d4bb791..45ea149 100644 --- a/tests/unit/components/PublicOrderSummary.spec.js +++ b/tests/unit/components/PublicOrderSummary.spec.js @@ -6,8 +6,15 @@ import OrderCustomer from '@/components/order/OrderCustomer.vue' import OrderLines from '@/components/order/OrderLines.vue' import OrderTotal from '@/components/order/OrderTotal.vue' import OrderPayment from '@/components/order/OrderPayment.vue' +import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue' import vuetify from '@/plugins/vuetify' +const ProvenanceSectionStub = { + name: 'ProvenanceSection', + props: ['provenance'], + template: '
', +} + const saleData = { id: 5, code: 'abc123', @@ -33,7 +40,10 @@ const catalogData = { function mountSummary (props) { return mount(PublicOrderSummary, { props, - global: { plugins: [vuetify] }, + global: { + plugins: [vuetify], + stubs: { ProvenanceSection: ProvenanceSectionStub }, + }, }) } @@ -87,4 +97,28 @@ describe('PublicOrderSummary', () => { expect(wrapper.text()).not.toContain('Camilo') expect(wrapper.findComponent(OrderLines).exists()).toBe(false) }) + + it('no renderiza la sección de provenance cuando la respuesta no la incluye', () => { + const wrapper = mountSummary({ purchase: saleData }) + + expect(wrapper.findComponent(ProvenanceSection).exists()).toBe(false) + }) + + it('renderiza la sección de provenance cuando el resumen la incluye', () => { + const withProvenance = { + ...saleData, + product_provenance: [ + { + product: { id: 10, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' }, municipality: { id: 7, name: 'La Mesa' }, department: { id: 2, name: 'Cundinamarca' }, country: { id: 1, name: 'Colombia', code: 'CO' } }], + }, + ], + } + const wrapper = mountSummary({ purchase: withProvenance }) + + expect(wrapper.findComponent(ProvenanceSection).exists()).toBe(true) + expect(wrapper.findComponent(ProvenanceSection).props('provenance')).toEqual( + withProvenance.product_provenance + ) + }) }) diff --git a/tests/unit/components/graph/CytoscapeChart.spec.js b/tests/unit/components/graph/CytoscapeChart.spec.js index 81cca6e..323d7b8 100644 --- a/tests/unit/components/graph/CytoscapeChart.spec.js +++ b/tests/unit/components/graph/CytoscapeChart.spec.js @@ -1,3 +1,4 @@ +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' @@ -17,8 +18,6 @@ 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 } }, diff --git a/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js b/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js new file mode 100644 index 0000000..ab71d38 --- /dev/null +++ b/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js @@ -0,0 +1,87 @@ +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), +})) + +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, + }, + ], + }, +] + +function mountChart (props = {}) { + return mount(ProductSupplierOrganizationChart, { + props: { provenance, ...props }, + global: { plugins: [vuetify] }, + }) +} + +describe('ProductSupplierOrganizationChart', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renderiza el gráfico con los elementos de productos, proveedores y organizaciones', () => { + const wrapper = mountChart() + + const chart = wrapper.findComponent(CytoscapeChart) + expect(chart.exists()).toBe(true) + const elements = chart.props('elements') + const nodeIds = elements.filter(e => e.data.source === undefined).map(e => e.data.id) + expect(nodeIds).toContain('product:1') + expect(nodeIds).toContain('supplier:5') + expect(nodeIds).toContain('organization:3') + }) + + 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(wrapper.findComponent(CytoscapeChart).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' } }) + + expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true) + expect(document.body.textContent).toContain('Asociación Agropecuaria La Mesa') + }) +}) diff --git a/tests/unit/components/provenance/ProductTerritoryChart.spec.js b/tests/unit/components/provenance/ProductTerritoryChart.spec.js new file mode 100644 index 0000000..528df3b --- /dev/null +++ b/tests/unit/components/provenance/ProductTerritoryChart.spec.js @@ -0,0 +1,84 @@ +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), +})) + +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' }, + }, + ], + }, +] + +function mountChart (props = {}) { + return mount(ProductTerritoryChart, { + props: { provenance, ...props }, + global: { plugins: [vuetify] }, + }) +} + +describe('ProductTerritoryChart', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renderiza el gráfico con municipio, departamento y país', () => { + const wrapper = mountChart() + + const chart = wrapper.findComponent(CytoscapeChart) + expect(chart.exists()).toBe(true) + const elements = chart.props('elements') + const nodeIds = elements.filter(e => e.data.source === undefined).map(e => e.data.id) + expect(nodeIds).toContain('product:1') + expect(nodeIds).toContain('supplier:5') + expect(nodeIds).toContain('municipality:7') + expect(nodeIds).toContain('department:2') + expect(nodeIds).toContain('country: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(wrapper.findComponent(CytoscapeChart).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' } }) + + expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true) + expect(document.body.textContent).toContain('La Mesa') + }) +}) diff --git a/tests/unit/components/provenance/ProvenanceDetailModal.spec.js b/tests/unit/components/provenance/ProvenanceDetailModal.spec.js new file mode 100644 index 0000000..ecbf0ea --- /dev/null +++ b/tests/unit/components/provenance/ProvenanceDetailModal.spec.js @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue' +import vuetify from '@/plugins/vuetify' + +const supplier = { + id: 'supplier:5', + kind: 'supplier', + label: 'Asociación Agropecuaria La Mesa', + image: null, + entity: { + id: 5, + name: 'Asociación Agropecuaria La Mesa', + description: 'Cooperativa de campesinos', + website: 'https://agro.example.org', + contact_email: 'contacto@agro.example.org', + contact_phone: '300 123 4567', + kind: 'supplier', + }, +} + +const product = { + id: 'product:1', + kind: 'product', + label: 'Panela regional por Kg', + image: 'http://localhost/media/panela.jpg', + entity: { id: 1, name: 'Panela regional por Kg', kind: 'product' }, +} + +function mountModal (props) { + return mount(ProvenanceDetailModal, { + props, + global: { plugins: [vuetify] }, + }) +} + +function bodyText () { + return document.body.textContent +} + +describe('ProvenanceDetailModal', () => { + it('muestra la información detallada del proveedor seleccionado', () => { + mountModal({ visible: true, selected: supplier }) + + expect(bodyText()).toContain('Proveedor') + expect(bodyText()).toContain('Asociación Agropecuaria La Mesa') + expect(bodyText()).toContain('Cooperativa de campesinos') + expect(bodyText()).toContain('https://agro.example.org') + expect(bodyText()).toContain('contacto@agro.example.org') + expect(bodyText()).toContain('300 123 4567') + }) + + it('muestra la imagen del producto cuando existe', () => { + mountModal({ visible: true, selected: product }) + + expect(document.querySelector('img').getAttribute('src')).toBe( + 'http://localhost/media/panela.jpg' + ) + }) + + it('no muestra contenido cuando no hay entidad seleccionada', () => { + mountModal({ visible: true, selected: null }) + + expect(bodyText()).not.toContain('Proveedor') + expect(bodyText()).not.toContain('Asociación') + }) + + it('no muestra el diálogo cuando visible es false', () => { + mountModal({ visible: false, selected: supplier }) + + expect(bodyText()).not.toContain('Asociación Agropecuaria La Mesa') + }) + + it('omite los campos vacíos', () => { + const minimal = { + id: 'country:1', + kind: 'country', + label: 'Colombia', + image: null, + entity: { id: 1, name: 'Colombia', kind: 'country' }, + } + mountModal({ visible: true, selected: minimal }) + + expect(bodyText()).toContain('Colombia') + expect(bodyText()).not.toContain('Descripción') + expect(bodyText()).not.toContain('Sitio web') + }) +}) diff --git a/tests/unit/components/provenance/ProvenanceSection.spec.js b/tests/unit/components/provenance/ProvenanceSection.spec.js new file mode 100644 index 0000000..b8c8612 --- /dev/null +++ b/tests/unit/components/provenance/ProvenanceSection.spec.js @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import ProductSupplierOrganizationChart from '@/components/provenance/ProductSupplierOrganizationChart.vue' +import ProductTerritoryChart from '@/components/provenance/ProductTerritoryChart.vue' +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), +})) + +const provenance = [ + { + product: { id: 1, name: 'Panela regional por Kg' }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación 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' }, + }, + ], + }, +] + +function mountSection (props = {}) { + return mount(ProvenanceSection, { + props: { provenance, ...props }, + global: { plugins: [vuetify] }, + }) +} + +describe('ProvenanceSection', () => { + it('no renderiza nada cuando no hay provenance', () => { + const wrapper = mountSection({ provenance: null }) + + expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(false) + expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(false) + }) + + it('no renderiza nada cuando provenance es una lista vacía', () => { + const wrapper = mountSection({ provenance: [] }) + + expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(false) + expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(false) + }) + + it('muestra los dos gráficos con sus títulos cuando hay provenance', () => { + const wrapper = mountSection() + + expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(true) + expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(true) + expect(wrapper.text()).toContain('Proveedores y organizaciones') + expect(wrapper.text()).toContain('Departamento y municipio') + }) + + it('pasa el provenance a ambos gráficos', () => { + const wrapper = mountSection() + + expect(wrapper.findComponent(ProductSupplierOrganizationChart).props('provenance')).toStrictEqual(provenance) + expect(wrapper.findComponent(ProductTerritoryChart).props('provenance')).toStrictEqual(provenance) + }) +}) diff --git a/tests/unit/services/graph/graph-layout.spec.js b/tests/unit/services/graph/graph-layout.spec.js index eeb8b23..a05afef 100644 --- a/tests/unit/services/graph/graph-layout.spec.js +++ b/tests/unit/services/graph/graph-layout.spec.js @@ -87,4 +87,14 @@ describe('toCytoscapeElements', () => { 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') + }) })