#49 fix: marcador de producto sin imagen visible en el mapa de provenance
This commit is contained in:
@@ -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:<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), 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:<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), 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/<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 }`
|
||||
|
||||
248
src/components/provenance/ProvenanceMap.vue
Normal file
248
src/components/provenance/ProvenanceMap.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-alert
|
||||
v-if="!hasMarkers"
|
||||
class="my-4"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
>
|
||||
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
|
||||
</v-alert>
|
||||
|
||||
<template v-else>
|
||||
<div ref="mapEl" class="map-wrapper" />
|
||||
<v-alert
|
||||
v-if="!storePosition"
|
||||
class="mt-2"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
>
|
||||
La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
|
||||
</v-alert>
|
||||
<p class="text-caption text-medium-emphasis mt-2 mb-0">
|
||||
Pasa el cursor sobre un producto para ver su recorrido hasta la tienda.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<ProvenanceRelationModal
|
||||
:product="selectedProduct"
|
||||
:relation="selectedRelation"
|
||||
:visible="modalVisible"
|
||||
@update:visible="modalVisible = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import ProvenanceRelationModal from './ProvenanceRelationModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
provenance: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const api = inject('api')
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const mapEl = ref(null)
|
||||
const selectedProduct = ref(null)
|
||||
const selectedRelation = ref(null)
|
||||
const modalVisible = ref(false)
|
||||
|
||||
let map = null
|
||||
let storeMarker = null
|
||||
let productMarkers = []
|
||||
let activeLines = []
|
||||
|
||||
const settings = computed(() => settingsStore.settings)
|
||||
const storePosition = computed(() => {
|
||||
const current = settings.value
|
||||
if (!current || current.latitude == null || current.longitude == null) return null
|
||||
return [Number(current.latitude), Number(current.longitude)]
|
||||
})
|
||||
|
||||
const markers = computed(() => {
|
||||
const result = []
|
||||
for (const entry of props.provenance || []) {
|
||||
if (!entry.product) continue
|
||||
for (const rel of entry.suppliers || []) {
|
||||
const municipality = rel.municipality
|
||||
if (municipality && municipality.latitude != null && municipality.longitude != null) {
|
||||
result.push({
|
||||
product: entry.product,
|
||||
relation: rel,
|
||||
position: [Number(municipality.latitude), Number(municipality.longitude)],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const hasMarkers = computed(() => markers.value.length > 0)
|
||||
|
||||
const personIcon = L.divIcon({
|
||||
className: 'provenance-store-pin',
|
||||
html: '<i class="mdi mdi-account-circle" style="font-size:46px;line-height:1;color:#f44336;"></i>',
|
||||
iconSize: [46, 46],
|
||||
iconAnchor: [23, 46],
|
||||
})
|
||||
|
||||
function productIcon (markerData) {
|
||||
const image = markerData.product.catalogue_images?.[0]
|
||||
const html = image
|
||||
? `<img class="provenance-product-img" src="${image}" alt="${markerData.product.name}">`
|
||||
: '<span class="provenance-product-fallback"><i class="mdi mdi-package-variant"></i></span>'
|
||||
return L.divIcon({
|
||||
className: 'provenance-product-pin',
|
||||
html,
|
||||
iconSize: [38, 38],
|
||||
iconAnchor: [19, 38],
|
||||
})
|
||||
}
|
||||
|
||||
function showLine (from, to) {
|
||||
const line = L.polyline([from, to], {
|
||||
color: '#26a69a',
|
||||
weight: 2,
|
||||
dashArray: '6 6',
|
||||
}).addTo(map)
|
||||
activeLines.push(line)
|
||||
}
|
||||
|
||||
function clearLines () {
|
||||
activeLines.forEach(line => line.remove())
|
||||
activeLines = []
|
||||
}
|
||||
|
||||
function addStoreMarker () {
|
||||
if (!storePosition.value) return
|
||||
storeMarker = L.marker(storePosition.value, { icon: personIcon }).addTo(map)
|
||||
storeMarker.bindTooltip('Tienda')
|
||||
storeMarker.on('mouseover', () => {
|
||||
markers.value.forEach(markerData => showLine(markerData.position, storePosition.value))
|
||||
})
|
||||
storeMarker.on('mouseout', clearLines)
|
||||
}
|
||||
|
||||
function addProductMarkers () {
|
||||
markers.value.forEach(markerData => {
|
||||
const marker = L.marker(markerData.position, { icon: productIcon(markerData) }).addTo(map)
|
||||
marker.bindTooltip(markerData.product.name)
|
||||
marker.on('click', () => openDialog(markerData))
|
||||
marker.on('mouseover', () => {
|
||||
if (storePosition.value) showLine(markerData.position, storePosition.value)
|
||||
})
|
||||
marker.on('mouseout', clearLines)
|
||||
productMarkers.push(marker)
|
||||
})
|
||||
}
|
||||
|
||||
function fitBounds () {
|
||||
const bounds = L.latLngBounds()
|
||||
markers.value.forEach(markerData => bounds.extend(markerData.position))
|
||||
if (storePosition.value) bounds.extend(storePosition.value)
|
||||
if (bounds.isValid()) map.fitBounds(bounds, { padding: [40, 40] })
|
||||
}
|
||||
|
||||
function openDialog (markerData) {
|
||||
selectedProduct.value = markerData.product
|
||||
selectedRelation.value = markerData.relation
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function initMap () {
|
||||
if (!mapEl.value || map) return
|
||||
const center = storePosition.value || markers.value[0].position
|
||||
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(center, 6)
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map)
|
||||
addStoreMarker()
|
||||
addProductMarkers()
|
||||
fitBounds()
|
||||
}
|
||||
|
||||
function handleResize () {
|
||||
if (map) map.invalidateSize()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
try {
|
||||
await settingsStore.fetchSettings(api)
|
||||
await nextTick()
|
||||
if (mapEl.value) initMap()
|
||||
} catch (error) {
|
||||
console.error('Error al cargar la configuración de la tienda:', error)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
storeMarker = null
|
||||
productMarkers = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.map-wrapper :deep(.provenance-product-pin) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.map-wrapper :deep(.provenance-product-img) {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.map-wrapper :deep(.provenance-product-fallback) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: #26a69a;
|
||||
color: #ffffff;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.map-wrapper :deep(.provenance-store-pin) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.map-wrapper :deep(.leaflet-control-attribution) {
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
126
src/components/provenance/ProvenanceRelationModal.vue
Normal file
126
src/components/provenance/ProvenanceRelationModal.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
max-width="560"
|
||||
:model-value="visible"
|
||||
@update:model-value="onUpdateVisible"
|
||||
>
|
||||
<v-card v-if="relation">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2" color="teal" icon="mdi-package-variant" />
|
||||
<span class="font-weight-bold">{{ productName }}</span>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<img
|
||||
v-if="productImage"
|
||||
alt="Imagen del producto"
|
||||
class="provenance-image rounded-lg border mb-3"
|
||||
:src="productImage"
|
||||
>
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.kind"
|
||||
class="d-flex align-start mb-3"
|
||||
>
|
||||
<v-icon class="mr-3" :color="row.color" :icon="row.icon" />
|
||||
<div>
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
{{ row.label }}
|
||||
</div>
|
||||
<div class="text-body-1">
|
||||
{{ row.name }}
|
||||
</div>
|
||||
<div
|
||||
v-for="detail in row.details"
|
||||
:key="detail.label"
|
||||
class="text-body-2 text-medium-emphasis"
|
||||
>
|
||||
{{ detail.label }}: {{ detail.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="close">Cerrar</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
relation: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
product: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
const ENTITY_INFO = {
|
||||
supplier: { label: 'Proveedor', icon: 'mdi-truck', color: 'blue' },
|
||||
organization: { label: 'Organización', icon: 'mdi-domain', color: 'orange' },
|
||||
municipality: { label: 'Municipio', icon: 'mdi-map-marker', color: 'green' },
|
||||
department: { label: 'Departamento', icon: 'mdi-map', color: 'indigo' },
|
||||
country: { label: 'País', icon: 'mdi-earth', color: 'purple' },
|
||||
}
|
||||
|
||||
const productName = computed(() => props.product?.name || 'Producto')
|
||||
const productImage = computed(() => props.product?.catalogue_images?.[0] || null)
|
||||
|
||||
function entityDetails (kind, entity) {
|
||||
const details = []
|
||||
if (kind === 'supplier' || kind === 'organization') {
|
||||
if (entity.description) details.push({ label: 'Descripción', value: entity.description })
|
||||
if (entity.website) details.push({ label: 'Sitio web', value: entity.website })
|
||||
if (entity.contact_email) details.push({ label: 'Correo', value: entity.contact_email })
|
||||
if (entity.contact_phone) details.push({ label: 'Teléfono', value: entity.contact_phone })
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
const rows = computed(() => {
|
||||
const rel = props.relation || {}
|
||||
return ['supplier', 'organization', 'municipality', 'department', 'country']
|
||||
.filter(kind => rel[kind])
|
||||
.map(kind => {
|
||||
const info = ENTITY_INFO[kind]
|
||||
return {
|
||||
kind,
|
||||
label: info.label,
|
||||
icon: info.icon,
|
||||
color: info.color,
|
||||
name: rel[kind].name,
|
||||
details: entityDetails(kind, rel[kind]),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function onUpdateVisible (value) {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
function close () {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provenance-image {
|
||||
display: block;
|
||||
max-height: 220px;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -28,12 +28,42 @@
|
||||
<ProvenanceGraph :provenance="provenance" />
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
<div
|
||||
class="d-flex align-center justify-space-between cursor-pointer"
|
||||
data-test="map-toggle"
|
||||
role="button"
|
||||
@click="mapExpanded = !mapExpanded"
|
||||
>
|
||||
<h3 class="text-h6 font-weight-bold">
|
||||
Mapa de origen de los productos
|
||||
</h3>
|
||||
<v-btn
|
||||
data-test="map-toggle-button"
|
||||
density="comfortable"
|
||||
:icon="mapExpanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
variant="text"
|
||||
@click.stop="mapExpanded = !mapExpanded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-expand-transition>
|
||||
<div v-if="mapExpanded">
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Recorrido de cada producto desde el municipio donde se produce hasta nuestra tienda.
|
||||
</p>
|
||||
|
||||
<ProvenanceMap :provenance="provenance" />
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import ProvenanceGraph from './ProvenanceGraph.vue'
|
||||
import ProvenanceMap from './ProvenanceMap.vue'
|
||||
|
||||
defineProps({
|
||||
provenance: {
|
||||
@@ -43,4 +73,5 @@
|
||||
})
|
||||
|
||||
const expanded = ref(false)
|
||||
const mapExpanded = ref(false)
|
||||
</script>
|
||||
|
||||
224
tests/unit/components/provenance/ProvenanceMap.spec.js
Normal file
224
tests/unit/components/provenance/ProvenanceMap.spec.js
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user