From 18026617cea1025c22ea62cf58531dcd6d8a7d11 Mon Sep 17 00:00:00 2001 From: monomono Date: Sun, 16 Aug 2026 04:10:39 -0500 Subject: [PATCH] #49 fix: marcador de producto sin imagen visible en el mapa de provenance --- AGENTS.md | 2 +- src/components/provenance/ProvenanceMap.vue | 248 ++++++++++++++++++ .../provenance/ProvenanceRelationModal.vue | 126 +++++++++ .../provenance/ProvenanceSection.vue | 31 +++ .../provenance/ProvenanceMap.spec.js | 224 ++++++++++++++++ .../ProvenanceRelationModal.spec.js | 81 ++++++ .../provenance/ProvenanceSection.spec.js | 42 ++- 7 files changed, 752 insertions(+), 2 deletions(-) create mode 100644 src/components/provenance/ProvenanceMap.vue create mode 100644 src/components/provenance/ProvenanceRelationModal.vue create mode 100644 tests/unit/components/provenance/ProvenanceMap.spec.js create mode 100644 tests/unit/components/provenance/ProvenanceRelationModal.spec.js diff --git a/AGENTS.md b/AGENTS.md index 0bf2639..94a637c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 }] }]`. - **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 -- **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:` (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), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto — patrón reutilizable para futuros bloques como el mapa leaflet. `ProvenanceDetailModal.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:` (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), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` renderiza un mapa leaflet con un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un producto abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). `ProvenanceDetailModal.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//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//` - Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }` diff --git a/src/components/provenance/ProvenanceMap.vue b/src/components/provenance/ProvenanceMap.vue new file mode 100644 index 0000000..eaab76b --- /dev/null +++ b/src/components/provenance/ProvenanceMap.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/src/components/provenance/ProvenanceRelationModal.vue b/src/components/provenance/ProvenanceRelationModal.vue new file mode 100644 index 0000000..afa5fe2 --- /dev/null +++ b/src/components/provenance/ProvenanceRelationModal.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/src/components/provenance/ProvenanceSection.vue b/src/components/provenance/ProvenanceSection.vue index 4f0fcc9..bbbf351 100644 --- a/src/components/provenance/ProvenanceSection.vue +++ b/src/components/provenance/ProvenanceSection.vue @@ -28,12 +28,42 @@ + + +
+

+ Mapa de origen de los productos +

+ +
+ + +
+

+ Recorrido de cada producto desde el municipio donde se produce hasta nuestra tienda. +

+ + +
+
diff --git a/tests/unit/components/provenance/ProvenanceMap.spec.js b/tests/unit/components/provenance/ProvenanceMap.spec.js new file mode 100644 index 0000000..e87d58c --- /dev/null +++ b/tests/unit/components/provenance/ProvenanceMap.spec.js @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import ProvenanceMap from '@/components/provenance/ProvenanceMap.vue' +import vuetify from '@/plugins/vuetify' + +const leaflet = vi.hoisted(() => { + const markers = [] + const polylines = [] + const layers = [] + function makeLayer (type) { + const layer = { + type, + addTo: vi.fn(function () { return this }), + on: vi.fn(), + bindTooltip: vi.fn(function () { return this }), + remove: vi.fn(), + } + layers.push(layer) + return layer + } + const map = { + setView: vi.fn(function () { return this }), + fitBounds: vi.fn(), + remove: vi.fn(), + invalidateSize: vi.fn(), + on: vi.fn(), + } + const bounds = { extend: vi.fn(), isValid: vi.fn(() => true) } + return { + markers, + polylines, + layers, + map, + bounds, + L: { + map: vi.fn(() => map), + tileLayer: vi.fn(() => makeLayer('tileLayer')), + marker: vi.fn((pos, opts) => { + const marker = makeLayer('marker') + marker.pos = pos + marker.opts = opts + markers.push(marker) + return marker + }), + divIcon: vi.fn(opts => opts), + polyline: vi.fn((points, opts) => { + const polyline = makeLayer('polyline') + polyline.points = points + polyline.opts = opts + polylines.push(polyline) + return polyline + }), + latLngBounds: vi.fn(() => bounds), + }, + } +}) + +vi.mock('leaflet', () => ({ default: leaflet.L })) + +const settings = { latitude: 4.6, longitude: -74.08, address: 'Cra 1 #2-3' } + +const provenance = [ + { + product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación La Mesa' }, + organization: { id: 3, name: 'Red de Economía Solidaria' }, + municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 }, + }, + ], + }, + { + product: { id: 2, name: 'Arroz blanco', catalogue_images: [] }, + suppliers: [ + { supplier: { id: 6, name: 'Campesinos del Oriente' }, municipality: { id: 8, name: 'La Mesa', latitude: 4.86, longitude: -74.63 } }, + { supplier: { id: 7, name: 'Finca El Paraíso' }, municipality: { id: 9, name: 'San Antonio', latitude: 6.22, longitude: -75.57 } }, + ], + }, +] + +function mountMap (props = {}) { + const api = { getStoreSettings: vi.fn().mockResolvedValue(settings) } + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(ProvenanceMap, { + props: { provenance, ...props }, + global: { + plugins: [pinia, vuetify], + provide: { api }, + }, + }) + return { api, wrapper } +} + +function handlerOf (marker, event) { + return marker.on.mock.calls.find(args => args[0] === event)[1] +} + +beforeEach(() => { + vi.clearAllMocks() + leaflet.markers.length = 0 + leaflet.polylines.length = 0 + leaflet.layers.length = 0 + document.body.innerHTML = '' +}) + +describe('ProvenanceMap', () => { + it('carga la configuración de la tienda al montarse', async () => { + const { api } = mountMap() + await flushPromises() + + expect(api.getStoreSettings).toHaveBeenCalled() + }) + + it('crea el mapa con la tienda y un marcador por municipio de origen', async () => { + mountMap() + await flushPromises() + + expect(leaflet.L.map).toHaveBeenCalledTimes(1) + expect(leaflet.L.tileLayer).toHaveBeenCalledTimes(1) + expect(leaflet.markers).toHaveLength(4) + expect(leaflet.map.fitBounds).toHaveBeenCalled() + expect(leaflet.markers[0].bindTooltip).toHaveBeenCalled() + }) + + it('al pasar sobre un producto dibuja una línea hasta la tienda', async () => { + mountMap() + await flushPromises() + + handlerOf(leaflet.markers[1], 'mouseover')() + + expect(leaflet.L.polyline).toHaveBeenCalledTimes(1) + expect(leaflet.L.polyline.mock.calls[0][0]).toEqual([[6.23, -75.56], [4.6, -74.08]]) + expect(leaflet.polylines[0].addTo).toHaveBeenCalled() + }) + + it('al pasar sobre la tienda dibuja las líneas de todos los productos', async () => { + mountMap() + await flushPromises() + + handlerOf(leaflet.markers[0], 'mouseover')() + + expect(leaflet.L.polyline).toHaveBeenCalledTimes(3) + }) + + it('al salir del marcador se limpian las líneas', async () => { + mountMap() + await flushPromises() + + handlerOf(leaflet.markers[1], 'mouseover')() + expect(leaflet.polylines).toHaveLength(1) + handlerOf(leaflet.markers[1], 'mouseout')() + + expect(leaflet.polylines[0].remove).toHaveBeenCalled() + }) + + it('al hacer clic en un producto abre el diálogo con el proveedor', async () => { + mountMap() + await flushPromises() + + handlerOf(leaflet.markers[1], 'click')() + await nextTick() + + expect(document.body.textContent).toContain('Asociación La Mesa') + expect(document.body.textContent).toContain('Panela regional') + }) + + it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => { + const api = { getStoreSettings: vi.fn().mockResolvedValue({ address: 'Cra 1' }) } + const pinia = createPinia() + setActivePinia(pinia) + mount(ProvenanceMap, { + props: { provenance }, + global: { + plugins: [pinia, vuetify], + provide: { api }, + }, + }) + await flushPromises() + + expect(leaflet.markers).toHaveLength(3) + handlerOf(leaflet.markers[0], 'mouseover')() + expect(leaflet.L.polyline).not.toHaveBeenCalled() + }) + + it('muestra un aviso cuando ningún municipio tiene coordenadas', async () => { + const noCoords = [ + { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'M' } }] }, + ] + const { wrapper } = mountMap({ provenance: noCoords }) + await flushPromises() + + expect(leaflet.L.map).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('coordenadas') + }) + + it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => { + const backendProvenance = [ + { + product: { id: 110, name: 'Panela condimentada 150 grs', catalogue_images: [] }, + suppliers: [ + { + supplier: { id: 4, name: 'Asociación Agropecuaria La Mesa' }, + organization: null, + municipality: { id: 2653, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' }, + department: { id: 91, name: 'Cundinamarca' }, + country: { id: 3, name: 'Colombia', code: 'CO' }, + }, + ], + }, + ] + mountMap({ provenance: backendProvenance }) + await flushPromises() + + expect(leaflet.L.map).toHaveBeenCalledTimes(1) + expect(leaflet.markers).toHaveLength(2) + expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588]) + const productIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].html.includes('provenance-product-fallback')) + expect(productIcon).toBeDefined() + }) +}) diff --git a/tests/unit/components/provenance/ProvenanceRelationModal.spec.js b/tests/unit/components/provenance/ProvenanceRelationModal.spec.js new file mode 100644 index 0000000..942c3d6 --- /dev/null +++ b/tests/unit/components/provenance/ProvenanceRelationModal.spec.js @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import ProvenanceRelationModal from '@/components/provenance/ProvenanceRelationModal.vue' +import vuetify from '@/plugins/vuetify' + +const relation = { + supplier: { id: 5, name: 'Asociación La Mesa', contact_email: 'info@mesa.co' }, + organization: { id: 3, name: 'Red de Economía Solidaria' }, + municipality: { id: 7, name: 'Santa Bárbara' }, + department: { id: 2, name: 'Antioquia' }, + country: { id: 1, name: 'Colombia', code: 'CO' }, +} +const product = { + id: 1, + name: 'Panela regional', + catalogue_images: ['http://localhost/media/panela.jpg'], +} + +function mountModal (props = {}) { + return mount(ProvenanceRelationModal, { + props: { product, relation, visible: true, ...props }, + global: { plugins: [vuetify] }, + }) +} + +function bodyText () { + return document.body.textContent +} + +describe('ProvenanceRelationModal', () => { + it('muestra el producto y todas las entidades de la relación', () => { + mountModal() + + expect(bodyText()).toContain('Panela regional') + expect(bodyText()).toContain('Proveedor') + expect(bodyText()).toContain('Asociación La Mesa') + expect(bodyText()).toContain('Organización') + expect(bodyText()).toContain('Red de Economía Solidaria') + expect(bodyText()).toContain('Municipio') + expect(bodyText()).toContain('Santa Bárbara') + expect(bodyText()).toContain('Departamento') + expect(bodyText()).toContain('Antioquia') + expect(bodyText()).toContain('País') + expect(bodyText()).toContain('Colombia') + }) + + it('incluye los detalles de contacto del proveedor', () => { + mountModal() + + expect(bodyText()).toContain('info@mesa.co') + }) + + it('muestra la imagen del producto cuando existe', () => { + mountModal() + + expect(document.querySelector('img').getAttribute('src')).toBe( + 'http://localhost/media/panela.jpg' + ) + }) + + it('no muestra las entidades ausentes', () => { + mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } }) + + expect(bodyText()).not.toContain('Organización') + expect(bodyText()).not.toContain('Municipio') + expect(bodyText()).not.toContain('Departamento') + expect(bodyText()).not.toContain('País') + }) + + it('no muestra contenido cuando no hay relación', () => { + mountModal({ relation: null }) + + expect(bodyText()).not.toContain('Proveedor') + }) + + it('no muestra el diálogo cuando visible es false', () => { + mountModal({ visible: false }) + + expect(bodyText()).not.toContain('Asociación La Mesa') + }) +}) diff --git a/tests/unit/components/provenance/ProvenanceSection.spec.js b/tests/unit/components/provenance/ProvenanceSection.spec.js index 497b07a..adc5892 100644 --- a/tests/unit/components/provenance/ProvenanceSection.spec.js +++ b/tests/unit/components/provenance/ProvenanceSection.spec.js @@ -30,10 +30,14 @@ const provenance = [ function mountSection (props = {}) { return mount(ProvenanceSection, { props: { provenance, ...props }, - global: { plugins: [vuetify] }, + global: { plugins: [vuetify], stubs: { ProvenanceMap: true } }, }) } +function mapWrapper (wrapper) { + return wrapper.findComponent({ name: 'ProvenanceMap' }) +} + describe('ProvenanceSection', () => { it('no renderiza nada cuando no hay provenance', () => { const wrapper = mountSection({ provenance: null }) @@ -84,4 +88,40 @@ describe('ProvenanceSection', () => { expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance) }) + + it('oculta el mapa por defecto', () => { + const wrapper = mountSection() + + expect(mapWrapper(wrapper).exists()).toBe(false) + }) + + it('muestra el mapa al hacer clic en su título y lo oculta al volver a hacer clic', async () => { + const wrapper = mountSection() + + await wrapper.find('[data-test="map-toggle"]').trigger('click') + expect(mapWrapper(wrapper).exists()).toBe(true) + + await wrapper.find('[data-test="map-toggle"]').trigger('click') + expect(mapWrapper(wrapper).exists()).toBe(false) + }) + + it('también despliega y repliega el mapa con el botón de chevron', async () => { + const wrapper = mountSection() + + const button = wrapper.find('[data-test="map-toggle-button"]') + expect(button.exists()).toBe(true) + await button.trigger('click') + expect(mapWrapper(wrapper).exists()).toBe(true) + + await button.trigger('click') + expect(mapWrapper(wrapper).exists()).toBe(false) + }) + + it('pasa el provenance al mapa al desplegarlo', async () => { + const wrapper = mountSection() + + await wrapper.find('[data-test="map-toggle"]').trigger('click') + + expect(mapWrapper(wrapper).props('provenance')).toStrictEqual(provenance) + }) })