#49 fix: marcador de producto sin imagen visible en el mapa de provenance

This commit is contained in:
2026-08-16 04:10:39 -05:00
parent 6200fa7432
commit 18026617ce
7 changed files with 752 additions and 2 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 }] }]`.
- **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 }`

View 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: '&copy; <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>

View 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>

View File

@@ -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>

View 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()
})
})

View File

@@ -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')
})
})

View File

@@ -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)
})
})