5 Commits

6 changed files with 614 additions and 937 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 }] }]`. - Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
- **Genérico reutilizable:** - **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 - `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, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— y, si hay al menos un municipio con coordenadas, el mapa leaflet debajo: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan; los productos del mismo punto se agrupan en un único marcador con contador que al hacer clic despliega un popup con la lista de productos internos para abrir cada uno) 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; un botón flotante (`data-test="map-reset-zoom"`) re-ejecuta `fitBounds` para volver al zoom general; clic en un marcador individual abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el diálogo; si no, solo abre el diálogo (que para productos sin geolocalización muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada.", y para productos sin proveedor la nota "Aún no se ha vinculado un proveedor a este producto."). `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` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— como panel lateral izquierdo junto al mapa (en columna en pantallas < 900px) y, si hay al menos un municipio con coordenadas, el mapa leaflet al lado: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan; los productos del mismo punto se agrupan en un único marcador con contador que al hacer clic despliega un popup con la lista de productos internos para abrir cada uno) 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; un botón flotante en el título del panel (`data-test="map-reset-zoom"`) re-ejecuta `fitBounds` para volver al zoom general; clic en un marcador individual abre un **popup de leaflet** anclado al ícono con el detalle (imagen + producto + proveedor + organización + territorio); clic en un producto del popup agrupado reemplaza su contenido por el detalle de ese producto. Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el popup de detalle en el marcador; si no, abre `ProvenanceRelationModal.vue` (único caso donde se usa el modal web, pues no hay ícono en el mapa) que muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada." o "Aún no se ha vinculado un proveedor a este producto.". `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` - **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>/` - **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 }` - Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`

1105
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,63 +1,71 @@
<template> <template>
<div> <div>
<template v-if="productRows.length"> <template v-if="productRows.length">
<v-card class="mb-3" data-test="map-product-box" variant="tonal"> <div class="map-layout">
<v-list density="compact"> <v-card class="product-panel" data-test="map-product-box" variant="tonal">
<v-list-item <v-card-title class="d-flex align-center justify-space-between py-2">
v-for="row in productRows" <span class="text-subtitle-1 font-weight-bold">
:key="row.product.id" Productos ({{ productRows.length }})
data-test="map-product-row" </span>
:prepend-avatar="row.image" <v-btn
role="button" v-if="hasMarkers"
:subtitle="row.locationLabel" data-test="map-reset-zoom"
:title="row.product.name" density="comfortable"
@click="onRowClick(row)" icon="mdi-fit-to-page"
> size="small"
<template #append> variant="flat"
<v-icon v-if="row.position" color="primary" size="small"> @click="resetZoom"
mdi-map-marker />
</v-icon> </v-card-title>
<v-icon v-else color="medium-emphasis" size="small"> <v-divider />
mdi-map-marker-off <v-list density="compact">
</v-icon> <v-list-item
</template> v-for="row in productRows"
</v-list-item> :key="row.product.id"
</v-list> data-test="map-product-row"
</v-card> :prepend-avatar="row.image"
role="button"
:subtitle="row.locationLabel"
:title="row.product.name"
@click="onRowClick(row)"
>
<template #append>
<v-icon v-if="row.position" color="primary" size="small">
mdi-map-marker
</v-icon>
<v-icon v-else color="medium-emphasis" size="small">
mdi-map-marker-off
</v-icon>
</template>
</v-list-item>
</v-list>
</v-card>
<template v-if="hasMarkers"> <div class="map-area">
<div class="map-container"> <template v-if="hasMarkers">
<div ref="mapEl" class="map-wrapper" /> <div ref="mapEl" class="map-wrapper" />
<v-btn <v-alert
class="map-reset-zoom" v-if="!storePosition"
data-test="map-reset-zoom" class="mt-2"
density="comfortable" type="warning"
icon="mdi-fit-to-page" variant="tonal"
size="small" >
variant="flat" La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
@click="resetZoom" </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. Haz clic en un marcador para ver el detalle.
</p>
</template>
<v-alert
v-else
class="my-2"
type="info"
variant="tonal"
>
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
</v-alert>
</div> </div>
<v-alert </div>
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>
<v-alert
v-else
class="my-2"
type="info"
variant="tonal"
>
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
</v-alert>
</template> </template>
<ProvenanceRelationModal <ProvenanceRelationModal
@@ -188,7 +196,7 @@
}) })
} }
function buildGroupPopup (items) { function buildGroupPopup (items, onItemClick) {
const container = document.createElement('div') const container = document.createElement('div')
container.className = 'provenance-popup-list' container.className = 'provenance-popup-list'
items.forEach((item, index) => { items.forEach((item, index) => {
@@ -207,12 +215,67 @@
name.className = 'provenance-popup-name' name.className = 'provenance-popup-name'
name.textContent = item.product.name name.textContent = item.product.name
row.appendChild(name) row.appendChild(name)
row.addEventListener('click', () => openDialog(item)) row.addEventListener('click', event => {
event.stopPropagation()
onItemClick(item)
})
container.appendChild(row) container.appendChild(row)
}) })
return container return container
} }
function appendEntityRow (container, label, icon, color, entity) {
if (!entity) return
const row = document.createElement('div')
row.className = 'provenance-detail-row'
const iconEl = document.createElement('i')
iconEl.className = `mdi ${icon}`
iconEl.style.color = color
row.appendChild(iconEl)
const body = document.createElement('div')
const labelEl = document.createElement('div')
labelEl.className = 'provenance-detail-label'
labelEl.textContent = label
const nameEl = document.createElement('div')
nameEl.className = 'provenance-detail-name'
nameEl.textContent = entity.name
body.appendChild(labelEl)
body.appendChild(nameEl)
row.appendChild(body)
container.appendChild(row)
}
function buildDetailPopup (markerData) {
const container = document.createElement('div')
container.className = 'provenance-detail-popup'
const header = document.createElement('div')
header.className = 'provenance-detail-header'
const image = markerData.product.catalogue_images?.[0]
if (image) {
const img = document.createElement('img')
img.src = image
img.className = 'provenance-detail-img'
header.appendChild(img)
}
const title = document.createElement('div')
title.className = 'provenance-detail-title'
title.textContent = markerData.product.name
header.appendChild(title)
container.appendChild(header)
const rel = markerData.relation || {}
appendEntityRow(container, 'Proveedor', 'mdi-truck', '#1976d2', rel.supplier)
appendEntityRow(container, 'Organización', 'mdi-domain', '#fb8c00', rel.organization)
appendEntityRow(container, 'Municipio', 'mdi-map-marker', '#4caf50', rel.municipality)
appendEntityRow(container, 'Departamento', 'mdi-map', '#3f51b5', rel.department)
appendEntityRow(container, 'País', 'mdi-earth', '#9c27b0', rel.country)
return container
}
function openProductDetail (marker, markerData) {
marker.setPopupContent(buildDetailPopup(markerData))
marker.openPopup()
}
function showLine (from, to) { function showLine (from, to) {
const line = L.polyline([from, to], { const line = L.polyline([from, to], {
color: '#26a69a', color: '#26a69a',
@@ -243,22 +306,25 @@
const item = group.items[0] const item = group.items[0]
const marker = L.marker(item.position, { icon: productIcon(item) }).addTo(map) const marker = L.marker(item.position, { icon: productIcon(item) }).addTo(map)
marker.bindTooltip(item.product.name) marker.bindTooltip(item.product.name)
marker.on('click', () => openDialog(item)) marker.bindPopup(buildDetailPopup(item))
marker.on('mouseover', () => { marker.on('mouseover', () => {
if (storePosition.value) showLine(item.position, storePosition.value) if (storePosition.value) showLine(item.position, storePosition.value)
}) })
marker.on('mouseout', clearLines) marker.on('mouseout', clearLines)
productMarkers.push({ marker, data: item }) productMarkers.push({ marker, items: [item] })
return return
} }
const marker = L.marker(group.position, { icon: groupIcon(group) }).addTo(map) const marker = L.marker(group.position, { icon: groupIcon(group) }).addTo(map)
marker.bindTooltip(`${group.items.length} productos en este punto`) marker.bindTooltip(`${group.items.length} productos en este punto`)
marker.bindPopup(buildGroupPopup(group.items)) marker.bindPopup(buildGroupPopup(group.items, item => openProductDetail(marker, item)))
marker.on('popupclose', () => {
marker.bindPopup(buildGroupPopup(group.items, item => openProductDetail(marker, item)))
})
marker.on('mouseover', () => { marker.on('mouseover', () => {
if (storePosition.value) group.items.forEach(item => showLine(item.position, storePosition.value)) if (storePosition.value) group.items.forEach(item => showLine(item.position, storePosition.value))
}) })
marker.on('mouseout', clearLines) marker.on('mouseout', clearLines)
productMarkers.push({ marker, data: group.items }) productMarkers.push({ marker, items: group.items })
}) })
} }
@@ -272,6 +338,14 @@
function onRowClick (row) { function onRowClick (row) {
if (row.position && map) { if (row.position && map) {
map.flyTo(row.position, Math.max(map.getZoom(), 12), { duration: 0.6 }) map.flyTo(row.position, Math.max(map.getZoom(), 12), { duration: 0.6 })
const entry = productMarkers.find(entry =>
entry.items.some(item => item.product.id === row.product.id && item.relation === row.relation)
)
if (entry) {
const item = entry.items.find(i => i.relation === row.relation) || entry.items[0]
openProductDetail(entry.marker, item)
return
}
} }
selectedProduct.value = row.product selectedProduct.value = row.product
selectedRelation.value = row.relation selectedRelation.value = row.relation
@@ -282,12 +356,6 @@
if (map) fitBounds() if (map) fitBounds()
} }
function openDialog (markerData) {
selectedProduct.value = markerData.product
selectedRelation.value = markerData.relation
modalVisible.value = true
}
function initMap () { function initMap () {
if (!mapEl.value || map) return if (!mapEl.value || map) return
const center = storePosition.value || markers.value[0].position const center = storePosition.value || markers.value[0].position
@@ -328,8 +396,21 @@
</script> </script>
<style scoped> <style scoped>
.map-container { .map-layout {
position: relative; display: flex;
gap: 12px;
align-items: stretch;
}
.product-panel {
flex: 0 0 300px;
max-height: 480px;
overflow-y: auto;
}
.map-area {
flex: 1;
min-width: 0;
} }
.map-wrapper { .map-wrapper {
@@ -341,11 +422,15 @@
z-index: 0; z-index: 0;
} }
.map-reset-zoom { @media (max-width: 900px) {
position: absolute; .map-layout {
top: 12px; flex-direction: column;
right: 12px; }
z-index: 1000;
.product-panel {
flex-basis: auto;
max-height: none;
}
} }
.map-wrapper :deep(.provenance-product-pin) { .map-wrapper :deep(.provenance-product-pin) {
@@ -432,6 +517,52 @@
font-weight: 500; font-weight: 500;
} }
.map-wrapper :deep(.provenance-detail-popup) {
min-width: 200px;
max-width: 260px;
}
.map-wrapper :deep(.provenance-detail-header) {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.map-wrapper :deep(.provenance-detail-img) {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.map-wrapper :deep(.provenance-detail-title) {
font-weight: 700;
}
.map-wrapper :deep(.provenance-detail-row) {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px 0;
}
.map-wrapper :deep(.provenance-detail-row .mdi) {
font-size: 18px;
margin-top: 2px;
}
.map-wrapper :deep(.provenance-detail-label) {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
opacity: 0.7;
}
.map-wrapper :deep(.provenance-detail-name) {
font-weight: 500;
}
.map-wrapper :deep(.leaflet-control-attribution) { .map-wrapper :deep(.leaflet-control-attribution) {
font-size: 10px; font-size: 10px;
} }

View File

@@ -116,6 +116,39 @@
</v-card> </v-card>
</v-col> </v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-indigo text-white">🏷️ Categorías creadas ({{ result.created_categories?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.created_categories)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' }
]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title class="bg-deep-purple text-white">♻️ Categorías actualizadas ({{ result.updated_categories?.length || 0 }})</v-card-title>
<v-card-text>
<v-data-table
:items="formatResults(result.updated_categories)"
density="compact"
:headers="[
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Detalle', key: 'detail' }
]"
></v-data-table>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" class="text-center mt-4"> <v-col cols="12" class="text-center mt-4">
<v-btn color="primary" @click="$router.push('/')"> <v-btn color="primary" @click="$router.push('/')">
Volver al inicio Volver al inicio

View File

@@ -16,6 +16,9 @@ const leaflet = vi.hoisted(() => {
on: vi.fn(), on: vi.fn(),
bindTooltip: vi.fn(function () { return this }), bindTooltip: vi.fn(function () { return this }),
bindPopup: vi.fn(function () { return this }), bindPopup: vi.fn(function () { return this }),
setPopupContent: vi.fn(),
openPopup: vi.fn(),
closePopup: vi.fn(),
remove: vi.fn(), remove: vi.fn(),
getLatLng: vi.fn(function () { return this.pos }), getLatLng: vi.fn(function () { return this.pos }),
setLatLng: vi.fn(), setLatLng: vi.fn(),
@@ -173,15 +176,23 @@ describe('ProvenanceMap', () => {
expect(leaflet.polylines[0].remove).toHaveBeenCalled() expect(leaflet.polylines[0].remove).toHaveBeenCalled()
}) })
it('al hacer clic en un producto abre el diálogo con el proveedor', async () => { it('muestra el detalle del producto en un popup sobre el marcador', async () => {
mountMap() mountMap()
await flushPromises() await flushPromises()
handlerOf(leaflet.markers[1], 'click')() const marker = leaflet.markers[1]
await nextTick() expect(marker.bindPopup).toHaveBeenCalledTimes(1)
const popupEl = marker.bindPopup.mock.calls[0][0]
expect(popupEl.textContent).toContain('Panela regional')
expect(popupEl.textContent).toContain('Asociación La Mesa')
expect(popupEl.textContent).toContain('Santa Bárbara')
})
expect(document.body.textContent).toContain('Asociación La Mesa') it('no abre el modal web para productos con ubicación', async () => {
expect(document.body.textContent).toContain('Panela regional') mountMap()
await flushPromises()
expect(document.body.textContent).not.toContain('Proveedor')
}) })
it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => { it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => {
@@ -244,17 +255,24 @@ describe('ProvenanceMap', () => {
expect(popupEl.textContent).toContain('Arroz') expect(popupEl.textContent).toContain('Arroz')
}) })
it('al hacer clic en un producto del popup agrupado abre el diálogo', async () => { it('al hacer clic en un producto del popup agrupado muestra su detalle sin cerrar el popup ni propagar el clic al mapa', async () => {
mountMap({ provenance: sameMuni }) mountMap({ provenance: sameMuni })
await flushPromises() await flushPromises()
const popupEl = leaflet.markers[1].bindPopup.mock.calls[0][0] const groupMarker = leaflet.markers[1]
const popupEl = groupMarker.bindPopup.mock.calls[0][0]
let bubbled = false
popupEl.addEventListener('click', () => { bubbled = true })
const item = popupEl.querySelector('[data-test="map-popup-item-1"]') const item = popupEl.querySelector('[data-test="map-popup-item-1"]')
item.dispatchEvent(new Event('click')) item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(document.body.textContent).toContain('Arroz') expect(bubbled).toBe(false)
expect(document.body.textContent).toContain('B') expect(groupMarker.closePopup).not.toHaveBeenCalled()
expect(groupMarker.setPopupContent).toHaveBeenCalledTimes(1)
const detailEl = groupMarker.setPopupContent.mock.calls[0][0]
expect(detailEl.textContent).toContain('Arroz')
expect(detailEl.textContent).toContain('B')
expect(groupMarker.openPopup).toHaveBeenCalled()
}) })
it('al pasar sobre la tienda dibuja una línea por cada producto agrupado', async () => { it('al pasar sobre la tienda dibuja una línea por cada producto agrupado', async () => {
@@ -306,7 +324,7 @@ describe('ProvenanceMap', () => {
expect(rows[0].text()).toContain('Santa Bárbara') expect(rows[0].text()).toContain('Santa Bárbara')
}) })
it('al hacer clic en un producto con ubicación centra el mapa y abre el diálogo', async () => { it('al hacer clic en un producto con ubicación centra el mapa y abre su detalle en el marcador', async () => {
const { wrapper } = mountMap() const { wrapper } = mountMap()
await flushPromises() await flushPromises()
@@ -315,8 +333,12 @@ describe('ProvenanceMap', () => {
expect(leaflet.map.flyTo).toHaveBeenCalledTimes(1) expect(leaflet.map.flyTo).toHaveBeenCalledTimes(1)
expect(leaflet.map.flyTo.mock.calls[0][0]).toEqual([6.23, -75.56]) expect(leaflet.map.flyTo.mock.calls[0][0]).toEqual([6.23, -75.56])
expect(document.body.textContent).toContain('Asociación La Mesa') const marker = leaflet.markers[1]
expect(document.body.textContent).toContain('Panela regional') expect(marker.setPopupContent).toHaveBeenCalled()
expect(marker.setPopupContent.mock.calls[0][0].textContent).toContain('Panela regional')
expect(marker.setPopupContent.mock.calls[0][0].textContent).toContain('Asociación La Mesa')
expect(marker.openPopup).toHaveBeenCalled()
expect(document.body.textContent).not.toContain('Proveedor')
}) })
it('al hacer clic en un producto sin geolocalización abre el diálogo con la información disponible sin centrar el mapa', async () => { it('al hacer clic en un producto sin geolocalización abre el diálogo con la información disponible sin centrar el mapa', async () => {

View File

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import ProductsFromTrytonPage from '@/pages/sincronizar_productos_tryton.vue'
import vuetify from '@/plugins/vuetify'
const syncResult = {
checked_tryton_products: [{ id: 190, name: 'Producto 1' }],
created_products: [{ id: 3, name: 'Producto 1' }],
updated_products: [],
failed_products: [],
untouched_products: [],
created_categories: [
{ id: 9, name: 'Abarrotes', external_id: '5' },
],
updated_categories: [
{
id: 7,
name: 'Cereales',
external_id: '7',
detail: 'Nombre: Granos → Cereales',
},
],
}
function mockApi () {
return {
getProductsFromTryton: vi.fn().mockResolvedValue(syncResult),
}
}
async function mountPageAsAdmin (api) {
const pinia = createPinia()
setActivePinia(pinia)
const authStore = useAuthStore()
authStore.setUser({ role: 'administrator', username: 'admin' })
const wrapper = mount(ProductsFromTrytonPage, {
global: { plugins: [pinia, vuetify], provide: { api } },
})
await flushPromises()
await wrapper.vm.$nextTick()
return wrapper
}
async function startSync (wrapper) {
const button = wrapper
.findAll('button')
.find(b => b.text().includes('Iniciar Sincronización'))
await button.trigger('click')
await flushPromises()
await wrapper.vm.$nextTick()
}
describe('página sincronizar_productos_tryton', () => {
it('muestra las categorías creadas al sincronizar productos', async () => {
const wrapper = await mountPageAsAdmin(mockApi())
await startSync(wrapper)
expect(wrapper.text()).toContain('Categorías creadas (1)')
expect(wrapper.text()).toContain('Abarrotes')
})
it('muestra las categorías actualizadas con su detalle', async () => {
const wrapper = await mountPageAsAdmin(mockApi())
await startSync(wrapper)
expect(wrapper.text()).toContain('Categorías actualizadas (1)')
expect(wrapper.text()).toContain('Cereales')
expect(wrapper.text()).toContain('Nombre: Granos → Cereales')
})
it('mantiene el resumen de productos al sincronizar', async () => {
const api = mockApi()
const wrapper = await mountPageAsAdmin(api)
await startSync(wrapper)
expect(api.getProductsFromTryton).toHaveBeenCalled()
expect(wrapper.text()).toContain('✅ Creados (1)')
expect(wrapper.text()).toContain('Producto 1')
})
})