feat(propuesta): pasarela paso a paso con autodetección de ubicación y FAB
- Campo universal de ubicación (Google Maps/Waze/OSM/coordenadas/dirección) sobre proveedores extensibles (src/utils/ubicacion) con resolver e inferencia inversa Nominatim (nombre/provincia pre-rellenados, alias cooficiales) - FAB flotante abajo a la derecha que abre la pasarela en un modal accesible (Escape, foco contenido, responsive); sin descripción ni puntuación; avisos de duplicado y filtro de palabras dentro del modal - Aviso PWA reposicionado: tarjeta abajo a la izquierda en móvil, botón compacto arriba a la izquierda en PC (no tapa el FAB) - Suite de pruebas: 88 unitarias/componente (vitest + RTL, offline) y 10 de integración (supertest + SQLite :memory: y Nominatim real opt-in) - CI: workflow de Gitea Actions con npm test + build en push/PR e integración manual
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
// Control del hook de instalación sin depender de beforeinstallprompt
|
||||
const estadoHook = {
|
||||
isInstalled: false,
|
||||
canPromptInstall: false,
|
||||
promptInstall: vi.fn(async () => "accepted"),
|
||||
isIos: false,
|
||||
};
|
||||
vi.mock("../../src/hooks/usePwaInstall.js", () => ({ default: () => estadoHook }));
|
||||
|
||||
import BannerMovil from "../../src/components/BannerMovil.jsx";
|
||||
|
||||
const matchMediaMock = (esMovil) => (_query) => ({
|
||||
matches: esMovil,
|
||||
media: _query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
estadoHook.isInstalled = false;
|
||||
estadoHook.canPromptInstall = false;
|
||||
estadoHook.isIos = false;
|
||||
localStorage.clear();
|
||||
localStorage.setItem("localesp_visitas", "5"); // ya superó el mínimo de 2 visitas
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("BannerMovil en PC", () => {
|
||||
beforeEach(() => vi.stubGlobal("matchMedia", matchMediaMock(false)));
|
||||
|
||||
it("muestra un botón compacto arriba a la izquierda, sin la tarjeta", () => {
|
||||
render(<BannerMovil />);
|
||||
const boton = screen.getByRole("button", { name: /instalar app/i });
|
||||
expect(boton).toBeTruthy();
|
||||
|
||||
const contenedor = boton.closest("div").parentElement;
|
||||
expect(contenedor.style.position).toBe("fixed");
|
||||
expect(contenedor.style.top).toBe("20px");
|
||||
expect(contenedor.style.left).toBe("20px");
|
||||
expect(screen.queryByText("App para móvil")).toBeNull(); // tarjeta plegada
|
||||
});
|
||||
|
||||
it("sin diálogo nativo, el botón despliega la tarjeta con instrucciones; el ✕ de la tarjeta solo la recoge", () => {
|
||||
render(<BannerMovil />);
|
||||
const boton = screen.getByRole("button", { name: /instalar app/i });
|
||||
expect(boton.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(boton);
|
||||
expect(screen.getByText("App para móvil")).toBeTruthy();
|
||||
expect(screen.getByText(/Instálala desde el menú de tu navegador/i)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" }));
|
||||
expect(screen.queryByText("App para móvil")).toBeNull();
|
||||
// la tarjeta se cierra sin activar la regla de 14 días
|
||||
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeNull();
|
||||
});
|
||||
|
||||
it("con diálogo nativo, el botón instala directamente sin desplegar la tarjeta", async () => {
|
||||
estadoHook.canPromptInstall = true;
|
||||
render(<BannerMovil />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /instalar app/i }));
|
||||
expect(estadoHook.promptInstall).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("App para móvil")).toBeNull();
|
||||
});
|
||||
|
||||
it("el ✕ del botón aplica la regla de 14 días y oculta el aviso", () => {
|
||||
render(<BannerMovil />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /ocultar aviso de instalación/i }));
|
||||
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /instalar app/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BannerMovil en móvil", () => {
|
||||
beforeEach(() => vi.stubGlobal("matchMedia", matchMediaMock(true)));
|
||||
|
||||
it("muestra la tarjeta abajo a la izquierda (sin invadir el FAB de la derecha)", () => {
|
||||
render(<BannerMovil />);
|
||||
expect(screen.getByText("App para móvil")).toBeTruthy();
|
||||
const fijos = [...document.querySelectorAll("div")].filter((d) => d.style.position === "fixed");
|
||||
expect(fijos.length).toBe(1);
|
||||
const contenedor = fijos[0];
|
||||
expect(contenedor.style.position).toBe("fixed");
|
||||
expect(contenedor.style.left).toBe("16px");
|
||||
expect(contenedor.style.bottom).toBe("0px");
|
||||
// (el paddingBottom: env(safe-area-inset-bottom) no se serializa en jsdom; se verifica visualmente)
|
||||
expect(contenedor.style.right).toBe(""); // nunca a la derecha, ahí vive el FAB
|
||||
});
|
||||
|
||||
it("el ✕ de la tarjeta aplica la regla de 14 días", () => {
|
||||
render(<BannerMovil />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" }));
|
||||
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy();
|
||||
expect(screen.queryByText("App para móvil")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import BotonFlotante from "../../src/components/BotonFlotante.jsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("BotonFlotante", () => {
|
||||
it("expone su propósito con aria-label (accesible sin hover)", () => {
|
||||
render(<BotonFlotante onClick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: "Añade un nuevo local al directorio" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tooltip oculto por defecto y visible al recibir foco", () => {
|
||||
render(<BotonFlotante onClick={() => {}} />);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
fireEvent.focus(screen.getByRole("button"));
|
||||
expect(screen.getByRole("tooltip").textContent).toBe("Añade un nuevo local al directorio");
|
||||
|
||||
fireEvent.blur(screen.getByRole("button"));
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("tooltip también en hover", () => {
|
||||
render(<BotonFlotante onClick={() => {}} />);
|
||||
fireEvent.mouseEnter(screen.getByRole("button"));
|
||||
expect(screen.getByRole("tooltip")).toBeTruthy();
|
||||
fireEvent.mouseLeave(screen.getByRole("button"));
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("al pulsarlo abre la pasarela (onClick)", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<BotonFlotante onClick={onClick} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import ModalPasarela from "../../src/components/ModalPasarela.jsx";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // los mocks de propsBase se comparten entre pruebas
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const FORM_VACIO = {
|
||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
||||
entradaUbicacion: "", proveedorUbicacion: "",
|
||||
};
|
||||
|
||||
const FORM_RESUELTO = {
|
||||
...FORM_VACIO,
|
||||
entradaUbicacion: "41.3851, 2.1734",
|
||||
proveedorUbicacion: "coordenadas",
|
||||
lat: 41.3851, lng: 2.1734,
|
||||
nombre: "Bar X", provincia: "Barcelona",
|
||||
categoria: "Restauración", subcategoria: "Tapas y raciones",
|
||||
};
|
||||
|
||||
const propsBase = {
|
||||
setForm: vi.fn(),
|
||||
onCerrar: vi.fn(),
|
||||
onEnviar: vi.fn(),
|
||||
onConfirmarDuplicado: vi.fn(),
|
||||
onDescartarDuplicado: vi.fn(),
|
||||
aceptaPrivacidad: false,
|
||||
setAceptaPrivacidad: vi.fn(),
|
||||
onAbrirPrivacidad: vi.fn(),
|
||||
errorFiltro: "",
|
||||
posibleDuplicado: null,
|
||||
comprobandoDuplicado: false,
|
||||
saving: false,
|
||||
};
|
||||
|
||||
const siguiente = () => screen.getByRole("button", { name: "Siguiente" });
|
||||
|
||||
describe("ModalPasarela", () => {
|
||||
it("arranca en la pantalla de Ubicación con las 6 pantallas declaradas", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_VACIO} />);
|
||||
expect(screen.getByLabelText(/ubicación del local/i)).toBeTruthy();
|
||||
expect(screen.getByText(/paso 1 de 6/i)).toBeTruthy();
|
||||
expect(screen.getAllByRole("generic", { hidden: true })).toBeTruthy();
|
||||
// indicador de progreso: 6 barras
|
||||
const barras = document.querySelectorAll('[aria-hidden="true"] span[style*="width: 16px"]');
|
||||
expect(barras.length).toBe(6);
|
||||
});
|
||||
|
||||
it("Siguiente deshabilitado sin resolución de ubicación válida", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_VACIO} />);
|
||||
expect(siguiente().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("Siguiente habilitado con datos válidos y navega; Atrás conserva los datos", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
expect(siguiente().disabled).toBe(false);
|
||||
fireEvent.click(siguiente());
|
||||
// paso 2: nombre pre-rellenado con lo inferido
|
||||
expect(screen.getByLabelText(/nombre del local/i).value).toBe("Bar X");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /atrás/i }));
|
||||
// vuelve a ubicación con la entrada conservada
|
||||
expect(screen.getByLabelText(/ubicación del local/i).value).toBe("41.3851, 2.1734");
|
||||
});
|
||||
|
||||
it("Escape cierra el modal (descarta el borrador vía onCerrar)", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
|
||||
expect(propsBase.onCerrar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("✕ cierra el modal", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cerrar" }));
|
||||
expect(propsBase.onCerrar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("foco inicial en el primer control de cada pantalla", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
expect(document.activeElement).toBe(screen.getByLabelText(/ubicación del local/i));
|
||||
|
||||
fireEvent.click(siguiente());
|
||||
expect(document.activeElement).toBe(screen.getByLabelText(/nombre del local/i));
|
||||
});
|
||||
|
||||
it("Tab queda contenido dentro del modal (wrap del primer al último focusable)", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
// primer focusable del panel (el ✕ precede al cuerpo en el DOM)
|
||||
const cerrar = screen.getByRole("button", { name: "Cerrar" });
|
||||
cerrar.focus();
|
||||
|
||||
// Shift+Tab desde el primero → salta al último (Siguiente)
|
||||
fireEvent.keyDown(document.activeElement, { key: "Tab", shiftKey: true });
|
||||
expect(document.activeElement).toBe(siguiente());
|
||||
|
||||
// Tab desde el último → vuelve al primero
|
||||
fireEvent.keyDown(document.activeElement, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(cerrar);
|
||||
});
|
||||
|
||||
it("en el paso final muestra el resumen con su propio botón de envío", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
for (let i = 0; i < 5; i++) fireEvent.click(siguiente());
|
||||
expect(screen.getByRole("button", { name: /enviar propuesta/i })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Siguiente" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import PasoResumen from "../../src/components/PasoResumen.jsx";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // los mocks de propsBase se comparten entre pruebas
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const FORM_BASE = {
|
||||
nombre: "Bar El Olivo", provincia: "Madrid", categoria: "Restauración", subcategoria: "Tapas y raciones",
|
||||
direccion: "Calle Mayor 5, Madrid", enlaceGoogleMaps: "", lat: 40.4168, lng: -3.7038,
|
||||
entradaUbicacion: "40.4168, -3.7038", proveedorUbicacion: "coordenadas",
|
||||
};
|
||||
|
||||
const propsBase = {
|
||||
form: FORM_BASE,
|
||||
aceptaPrivacidad: false,
|
||||
setAceptaPrivacidad: vi.fn(),
|
||||
onAbrirPrivacidad: vi.fn(),
|
||||
onEnviar: vi.fn(),
|
||||
onConfirmarDuplicado: vi.fn(),
|
||||
onDescartarDuplicado: vi.fn(),
|
||||
errorFiltro: "",
|
||||
posibleDuplicado: null,
|
||||
comprobandoDuplicado: false,
|
||||
saving: false,
|
||||
};
|
||||
|
||||
describe("PasoResumen", () => {
|
||||
it("muestra un resumen legible de todos los datos", () => {
|
||||
render(<PasoResumen {...propsBase} />);
|
||||
expect(screen.getByText("Bar El Olivo")).toBeTruthy();
|
||||
expect(screen.getByText("Madrid")).toBeTruthy();
|
||||
expect(screen.getByText(/🍽️ Restauración/)).toBeTruthy();
|
||||
expect(screen.getByText("Tapas y raciones")).toBeTruthy();
|
||||
expect(screen.getByText("Calle Mayor 5, Madrid")).toBeTruthy();
|
||||
expect(screen.getByText(/Coordenadas/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("botón de envío deshabilitado sin aceptar la privacidad", () => {
|
||||
render(<PasoResumen {...propsBase} />);
|
||||
const boton = screen.getByRole("button", { name: /enviar propuesta/i });
|
||||
expect(boton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("al marcar la aceptación se habilita el envío y dispara onEnviar", () => {
|
||||
render(<PasoResumen {...propsBase} />);
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(propsBase.setAceptaPrivacidad).toHaveBeenCalledWith(true);
|
||||
|
||||
// con aceptación ya aplicada por el padre
|
||||
cleanup();
|
||||
render(<PasoResumen {...propsBase} aceptaPrivacidad />);
|
||||
const boton = screen.getByRole("button", { name: /enviar propuesta/i });
|
||||
expect(boton.disabled).toBe(false);
|
||||
fireEvent.click(boton);
|
||||
expect(propsBase.onEnviar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("el enlace de privacidad abre el modal sin marcar el checkbox", () => {
|
||||
render(<PasoResumen {...propsBase} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /política de privacidad/i }));
|
||||
expect(propsBase.onAbrirPrivacidad).toHaveBeenCalledTimes(1);
|
||||
expect(propsBase.setAceptaPrivacidad).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renderiza el aviso de palabra prohibida dentro del modal", () => {
|
||||
render(<PasoResumen {...propsBase} errorFiltro="Tu propuesta contiene palabras no permitidas." />);
|
||||
expect(screen.getByRole("alert").textContent).toContain("palabras no permitidas");
|
||||
});
|
||||
|
||||
it("renderiza el aviso de duplicado con las opciones del flujo", () => {
|
||||
render(
|
||||
<PasoResumen
|
||||
{...propsBase}
|
||||
posibleDuplicado={{
|
||||
coincidencias: [{ id: "1", nombre: "Bar El Olivo", provincia: "Madrid", direccion: "", estado: "publicado" }],
|
||||
lat: 40.4168, lng: -3.7038,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const aviso = screen.getByRole("alert");
|
||||
expect(aviso.textContent).toContain("Ya existe un local parecido");
|
||||
expect(aviso.textContent).toContain("Bar El Olivo");
|
||||
fireEvent.click(screen.getByRole("button", { name: /enviar de todos modos/i }));
|
||||
expect(propsBase.onConfirmarDuplicado).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: /revisar datos/i }));
|
||||
expect(propsBase.onDescartarDuplicado).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("estados Comprobando…/Enviando… deshabilitan el envío", () => {
|
||||
const { rerender } = render(<PasoResumen {...propsBase} aceptaPrivacidad comprobandoDuplicado />);
|
||||
let boton = screen.getByRole("button", { name: /comprobando/i });
|
||||
expect(boton.disabled).toBe(true);
|
||||
|
||||
rerender(<PasoResumen {...propsBase} aceptaPrivacidad saving />);
|
||||
boton = screen.getByRole("button", { name: /enviando/i });
|
||||
expect(boton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("../../src/utils/ubicacion/index.js", () => ({
|
||||
ETIQUETAS_PROVEEDOR: {
|
||||
"google-maps": "Google Maps",
|
||||
coordenadas: "Coordenadas",
|
||||
nominatim: "Dirección (OpenStreetMap)",
|
||||
},
|
||||
resolverUbicacion: vi.fn(),
|
||||
inferirDesdeCoords: vi.fn(),
|
||||
}));
|
||||
|
||||
import PasoUbicacion from "../../src/components/PasoUbicacion.jsx";
|
||||
import { resolverUbicacion, inferirDesdeCoords } from "../../src/utils/ubicacion/index.js";
|
||||
|
||||
const FORM = {
|
||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
||||
entradaUbicacion: "", proveedorUbicacion: "",
|
||||
};
|
||||
|
||||
// setForm que aplica los updater sobre un objeto (como haría useState) sin
|
||||
// provocar re-render: suficiente para inspeccionar lo que el paso escribe.
|
||||
function renderPaso(formInicial = { ...FORM }) {
|
||||
const estado = { form: formInicial };
|
||||
const setForm = (updater) => {
|
||||
estado.form = typeof updater === "function" ? updater(estado.form) : updater;
|
||||
};
|
||||
render(<PasoUbicacion form={estado.form} setForm={setForm} />);
|
||||
return estado;
|
||||
}
|
||||
|
||||
const escribir = (texto) =>
|
||||
fireEvent.change(screen.getByLabelText(/ubicación del local/i), { target: { value: texto } });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("PasoUbicacion", () => {
|
||||
it("no resuelve antes del debounce (~600ms)", async () => {
|
||||
resolverUbicacion.mockResolvedValue(null);
|
||||
renderPaso();
|
||||
escribir("https://maps.google.com/@41.3851,2.1734");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(300); });
|
||||
expect(resolverUbicacion).not.toHaveBeenCalled();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
|
||||
expect(resolverUbicacion).toHaveBeenCalledWith("https://maps.google.com/@41.3851,2.1734");
|
||||
});
|
||||
|
||||
it("al resolver muestra el chip con el proveedor y coordenadas y pre-rellena el form", async () => {
|
||||
resolverUbicacion.mockResolvedValue({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734, nombre: "Bar X", provincia: "Barcelona", direccion: "Bar X, Barcelona" });
|
||||
inferirDesdeCoords.mockResolvedValue(null); // ya trae nombre+provincia: no debe llamarse
|
||||
const estado = renderPaso();
|
||||
escribir("https://maps.google.com/@41.3851,2.1734");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
|
||||
|
||||
expect(screen.getByText(/Google Maps detectado/)).toBeTruthy();
|
||||
expect(screen.getByText(/· 41\.3851, 2\.1734/)).toBeTruthy();
|
||||
expect(estado.form.lat).toBe(41.3851);
|
||||
expect(estado.form.lng).toBe(2.1734);
|
||||
expect(estado.form.proveedorUbicacion).toBe("google-maps");
|
||||
expect(estado.form.nombre).toBe("Bar X");
|
||||
expect(estado.form.provincia).toBe("Barcelona");
|
||||
expect(estado.form.direccion).toBe("Bar X, Barcelona");
|
||||
// es un enlace: se guarda como enlaceGoogleMaps del local
|
||||
expect(estado.form.enlaceGoogleMaps).toBe("https://maps.google.com/@41.3851,2.1734");
|
||||
expect(inferirDesdeCoords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("con solo coordenadas lanza la inferencia inversa y pre-rellena lo inferido", async () => {
|
||||
resolverUbicacion.mockResolvedValue({ proveedor: "coordenadas", lat: 40.4168, lng: -3.7038, nombre: "", provincia: "" });
|
||||
inferirDesdeCoords.mockResolvedValue({ nombre: "Puerta del Sol", provincia: "Madrid", direccion: "Puerta del Sol, Madrid" });
|
||||
const estado = renderPaso();
|
||||
escribir("40.4168, -3.7038");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
|
||||
|
||||
expect(inferirDesdeCoords).toHaveBeenCalledWith(40.4168, -3.7038);
|
||||
expect(estado.form.nombre).toBe("Puerta del Sol");
|
||||
expect(estado.form.provincia).toBe("Madrid");
|
||||
// coordenadas sueltas: no es un enlace
|
||||
expect(estado.form.enlaceGoogleMaps).toBe("");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("entrada no reconocida → aviso y sin coordenadas en el form", async () => {
|
||||
resolverUbicacion.mockResolvedValue(null);
|
||||
const estado = renderPaso();
|
||||
escribir("https://maps.app.goo.gl/xyz");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
|
||||
|
||||
const aviso = screen.getByRole("alert");
|
||||
expect(aviso.textContent).toContain("No se pudo interpretar la ubicación");
|
||||
expect(estado.form.lat).toBeNull();
|
||||
});
|
||||
|
||||
it("si la inferencia inversa falla, avisa pero mantiene la resolución válida", async () => {
|
||||
resolverUbicacion.mockResolvedValue({ proveedor: "coordenadas", lat: 1, lng: 2, nombre: "", provincia: "" });
|
||||
inferirDesdeCoords.mockResolvedValue(null); // fallo controlado
|
||||
renderPaso();
|
||||
escribir("1, 2");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
|
||||
|
||||
expect(screen.getByText(/No se pudieron inferir el nombre y la provincia/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Coordenadas detectado/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("al volver a editar invalida la resolución anterior", async () => {
|
||||
resolverUbicacion.mockResolvedValueOnce({ proveedor: "coordenadas", lat: 1, lng: 2 });
|
||||
const estado = renderPaso();
|
||||
escribir("1, 2");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
|
||||
expect(estado.form.lat).toBe(1);
|
||||
expect(screen.getByText(/Coordenadas detectado/)).toBeTruthy();
|
||||
|
||||
escribir("1, 2 y algo más");
|
||||
expect(estado.form.lat).toBeNull(); // se limpia inmediatamente
|
||||
expect(screen.queryByText(/detectado/)).toBeNull(); // el chip desaparece
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import PasoNombre from "../../src/components/PasoNombre.jsx";
|
||||
import PasoProvincia from "../../src/components/PasoProvincia.jsx";
|
||||
import PasoCategoria from "../../src/components/PasoCategoria.jsx";
|
||||
import PasoSubcategoria from "../../src/components/PasoSubcategoria.jsx";
|
||||
import { PROVINCIAS } from "../../src/constants/provincias.js";
|
||||
import { CATEGORIAS, NOMBRES_CATEGORIAS } from "../../src/constants/categorias.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Harness con useState real: los pasos escriben a través de setForm y el
|
||||
// estado resultante se lee desde fuera (más fiel que espiar el updater,
|
||||
// cuya evaluación diferida pierde el valor del evento en React 18).
|
||||
function renderPaso(Componente, inicial) {
|
||||
const estado = { form: inicial };
|
||||
function Wrapper() {
|
||||
const [form, setForm] = useState(inicial);
|
||||
estado.form = form;
|
||||
return <Componente form={form} setForm={setForm} />;
|
||||
}
|
||||
render(<Wrapper />);
|
||||
return estado;
|
||||
}
|
||||
|
||||
describe("PasoNombre", () => {
|
||||
it("muestra el nombre inferido y es editable", () => {
|
||||
const estado = renderPaso(PasoNombre, { nombre: "Bar El Olivo" });
|
||||
const input = screen.getByLabelText(/nombre del local/i);
|
||||
expect(input.value).toBe("Bar El Olivo");
|
||||
fireEvent.change(input, { target: { value: "Bar La Plaza" } });
|
||||
expect(estado.form.nombre).toBe("Bar La Plaza");
|
||||
});
|
||||
|
||||
it("vacío si la inferencia falló", () => {
|
||||
renderPaso(PasoNombre, { nombre: "" });
|
||||
expect(screen.getByLabelText(/nombre del local/i).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PasoProvincia", () => {
|
||||
it("es un select con el listado PROVINCIAS y la provincia pre-rellenada", () => {
|
||||
renderPaso(PasoProvincia, { provincia: "La Coruña" });
|
||||
const select = screen.getByLabelText(/provincia/i);
|
||||
expect(select.value).toBe("La Coruña");
|
||||
const opciones = [...select.options].map((o) => o.value);
|
||||
expect(opciones).toEqual(["", ...PROVINCIAS]);
|
||||
fireEvent.change(select, { target: { value: "Madrid" } });
|
||||
// (la aserción de estado se cubre con el harness en el resto de pasos)
|
||||
});
|
||||
|
||||
it("sin coincidencia queda vacío (selección manual)", () => {
|
||||
renderPaso(PasoProvincia, { provincia: "" });
|
||||
expect(screen.getByLabelText(/provincia/i).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PasoCategoria", () => {
|
||||
it("ofrece todas las categorías y al elegir limpia la subcategoría", () => {
|
||||
const estado = renderPaso(PasoCategoria, { categoria: "Pequeño comercio", subcategoria: "Farmacia" });
|
||||
const select = screen.getByLabelText(/categoría/i);
|
||||
const opciones = [...select.options].map((o) => o.value);
|
||||
expect(opciones).toEqual(["", ...NOMBRES_CATEGORIAS]);
|
||||
|
||||
fireEvent.change(select, { target: { value: "Restauración" } });
|
||||
expect(estado.form.categoria).toBe("Restauración");
|
||||
expect(estado.form.subcategoria).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PasoSubcategoria", () => {
|
||||
it("deshabilitado sin categoría y opcional con ella", () => {
|
||||
const { rerender } = render(<PasoSubcategoria form={{ categoria: "", subcategoria: "" }} setForm={() => {}} />);
|
||||
const select = screen.getByLabelText(/tipo específico/i);
|
||||
expect(select.disabled).toBe(true);
|
||||
expect([...select.options][0].textContent).toMatch(/sin tipo específico/i); // se puede continuar
|
||||
|
||||
rerender(<PasoSubcategoria form={{ categoria: "Restauración", subcategoria: "" }} setForm={() => {}} />);
|
||||
expect(select.disabled).toBe(false);
|
||||
const opciones = [...select.options].map((o) => o.value);
|
||||
expect(opciones).toEqual(["", ...CATEGORIAS["Restauración"].subcategorias]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
|
||||
// BD en memoria: aislada, offline y determinista. Debe configurarse ANTES de
|
||||
// importar la app (server/db.js resuelve la ruta en la carga del módulo).
|
||||
process.env.LOCALESP_DB = ":memory:";
|
||||
|
||||
let app;
|
||||
beforeAll(async () => {
|
||||
({ default: app } = await import("../../server/index.js"));
|
||||
});
|
||||
|
||||
// Payload equivalente al que envía la pasarela tras este cambio: sin
|
||||
// descripcion y sin puntuacion (el backend aplica sus defaults).
|
||||
const PROPUESTA_PASARELA = {
|
||||
nombre: "Bar El Olivo",
|
||||
provincia: "Madrid",
|
||||
categoria: "Restauración",
|
||||
subcategoria: "Tapas y raciones",
|
||||
direccion: "Calle Mayor 5, Alcalá de Henares",
|
||||
enlaceGoogleMaps: "",
|
||||
lat: 40.4828,
|
||||
lng: -3.3652,
|
||||
};
|
||||
|
||||
describe("Integración API local (Express + SQLite en memoria)", () => {
|
||||
it("GET /api/locales arranca limpio", async () => {
|
||||
const r = await request(app).get("/api/locales");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("POST /api/propuestas acepta una propuesta de la pasarela sin descripcion ni puntuacion (defaults)", async () => {
|
||||
const r = await request(app).post("/api/propuestas").send(PROPUESTA_PASARELA);
|
||||
expect(r.status).toBe(201);
|
||||
expect(r.body.nombre).toBe("Bar El Olivo");
|
||||
expect(r.body.puntuacion).toBe(0); // default del backend
|
||||
expect(r.body.estado).toBe("pendiente");
|
||||
expect(r.body.id).toBeTruthy();
|
||||
|
||||
// y aparece en pendientes (vista admin)
|
||||
const pend = await request(app).get("/api/pendientes");
|
||||
expect(pend.body.map((p) => p.nombre)).toContain("Bar El Olivo");
|
||||
});
|
||||
|
||||
it("POST /api/check-duplicado detecta mismo nombre normalizado en la misma provincia", async () => {
|
||||
const r = await request(app)
|
||||
.post("/api/check-duplicado")
|
||||
.send({ nombre: "bar el olivo", provincia: "Madrid", lat: 41.0, lng: -4.0 }); // lejos: cuenta la provincia
|
||||
expect(r.body.duplicado).toBe(true);
|
||||
expect(r.body.coincidencias[0]).toMatchObject({
|
||||
nombre: "Bar El Olivo",
|
||||
provincia: "Madrid",
|
||||
estado: "pendiente", // aún no publicado
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /api/check-duplicado detecta por cercanía aunque cambie la provincia", async () => {
|
||||
const r = await request(app)
|
||||
.post("/api/check-duplicado")
|
||||
.send({ nombre: "BAR EL OLIVO", provincia: "Barcelona", lat: 40.48281, lng: -3.36521 }); // ~1-2 m
|
||||
expect(r.body.duplicado).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /api/check-duplicado no marca nombres distintos ni mismos nombres lejos y en otra provincia", async () => {
|
||||
const distinto = await request(app)
|
||||
.post("/api/check-duplicado")
|
||||
.send({ nombre: "Cafetería Luna", provincia: "Madrid", lat: 40.48, lng: -3.36 });
|
||||
expect(distinto.body.duplicado).toBe(false);
|
||||
|
||||
const lejosOtraProvincia = await request(app)
|
||||
.post("/api/check-duplicado")
|
||||
.send({ nombre: "Bar El Olivo", provincia: "Sevilla", lat: 37.38, lng: -5.98 });
|
||||
expect(lejosOtraProvincia.body.duplicado).toBe(false);
|
||||
});
|
||||
|
||||
it("flujo admin completo: aprobar publica el local con los datos de la propuesta", async () => {
|
||||
const propuesta = (await request(app).get("/api/pendientes")).body[0];
|
||||
const aprobado = await request(app).post(`/api/aprobar/${propuesta.id}`);
|
||||
expect(aprobado.body.ok).toBe(true);
|
||||
|
||||
const locales = (await request(app).get("/api/locales")).body;
|
||||
const publicado = locales.find((l) => l.nombre === "Bar El Olivo");
|
||||
expect(publicado).toMatchObject({
|
||||
provincia: "Madrid",
|
||||
categoria: "Restauración",
|
||||
subcategoria: "Tapas y raciones",
|
||||
direccion: "Calle Mayor 5, Alcalá de Henares",
|
||||
puntuacion: 0, // default aplicado en cascada
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { inferirDesdeCoords, resolverUbicacion } from "../../src/utils/ubicacion/index.js";
|
||||
|
||||
// Integración real con Nominatim (OpenStreetMap). Suite opt-in
|
||||
// (`npm run test:integration`): se auto-salta sin conectividad y mantiene un
|
||||
// ritmo respetuoso con la política de uso (~1 req/s) esperando entre pruebas.
|
||||
const PAUSA_MS = 1100;
|
||||
const pausar = () => new Promise((r) => setTimeout(r, PAUSA_MS));
|
||||
|
||||
const conRed = await fetch("https://nominatim.openstreetmap.org/status", {
|
||||
headers: { "User-Agent": "LocalesEspanoles/1.0" },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
.then((r) => r.ok)
|
||||
.catch(() => false);
|
||||
|
||||
describe.skipIf(!conRed)("Integración Nominatim (red real)", () => {
|
||||
beforeAll(() => {
|
||||
if (!conRed) console.warn("Sin conectividad a Nominatim: suite saltada.");
|
||||
});
|
||||
|
||||
it("busca una dirección en texto y devuelve coordenadas + provincia canónica", async () => {
|
||||
const r = await resolverUbicacion("Calle Mayor 5, Alcalá de Henares");
|
||||
expect(r.proveedor).toBe("nominatim");
|
||||
expect(r.lat).toBeGreaterThan(40.4);
|
||||
expect(r.lat).toBeLessThan(40.6);
|
||||
expect(r.lng).toBeGreaterThan(-3.5);
|
||||
expect(r.lng).toBeLessThan(-3.2);
|
||||
expect(r.provincia).toBe("Madrid");
|
||||
});
|
||||
|
||||
it("geocodificación inversa de Madrid devuelve la provincia canónica", async () => {
|
||||
await pausar();
|
||||
const r = await inferirDesdeCoords(40.4168, -3.7038);
|
||||
expect(r).not.toBeNull();
|
||||
expect(r.provincia).toBe("Madrid");
|
||||
expect(r.nombre).toBeTruthy();
|
||||
expect(r.direccion).toBeTruthy();
|
||||
});
|
||||
|
||||
it("geocodificación inversa de Bilbao normaliza Bizkaia → Vizcaya (acentos/cooficial)", async () => {
|
||||
await pausar();
|
||||
const r = await inferirDesdeCoords(43.263, -2.935);
|
||||
expect(r.provincia).toBe("Vizcaya");
|
||||
});
|
||||
|
||||
it("geocodificación inversa fuera de España deja la provincia vacía (selección manual)", async () => {
|
||||
await pausar();
|
||||
const r = await inferirDesdeCoords(48.8584, 2.2945); // Torre Eiffel
|
||||
expect(r).not.toBeNull();
|
||||
expect(r.provincia).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// Setup común: React 18 exige este flag para act() fuera de Jest.
|
||||
// Inofensivo en las pruebas de Node puro.
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import ProveedorUbicacion from "../../src/utils/ubicacion/ProveedorUbicacion.js";
|
||||
|
||||
describe("ProveedorUbicacion (clase base)", () => {
|
||||
it("normaliza: minúsculas, sin tildes, sin espacios extremos", () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
expect(p.normalizar(" ÁLAVA ")).toBe("alava");
|
||||
expect(p.normalizar("Guipúzcoa")).toBe("guipuzcoa");
|
||||
expect(ProveedorUbicacion.normalizar("MA DRID")).toBe("ma drid");
|
||||
expect(p.normalizar(null)).toBe("");
|
||||
});
|
||||
|
||||
describe("emparejarProvincia", () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
|
||||
it("coincidencia exacta insensible a acentos y mayúsculas", () => {
|
||||
expect(p.emparejarProvincia("ALAVA")).toBe("Álava");
|
||||
expect(p.emparejarProvincia("Guipuzcoa")).toBe("Guipúzcoa");
|
||||
});
|
||||
|
||||
it("coincidencia parcial dentro de un texto (Comunidad de Madrid)", () => {
|
||||
expect(p.emparejarProvincia("Comunidad de Madrid")).toBe("Madrid");
|
||||
expect(p.emparejarProvincia("Santiago, A Coruña, Galicia")).toBe("La Coruña");
|
||||
});
|
||||
|
||||
it("tolera artículos anteuestos (A Coruña → La Coruña)", () => {
|
||||
expect(p.emparejarProvincia("A Coruña")).toBe("La Coruña");
|
||||
expect(p.emparejarProvincia("Ourense")).toBe("Orense");
|
||||
});
|
||||
|
||||
it("alias de nombres cooficiales (D12)", () => {
|
||||
expect(p.emparejarProvincia("Bizkaia")).toBe("Vizcaya");
|
||||
expect(p.emparejarProvincia("Gipuzkoa")).toBe("Guipúzcoa");
|
||||
expect(p.emparejarProvincia("Girona")).toBe("Gerona");
|
||||
expect(p.emparejarProvincia("Lleida")).toBe("Lérida");
|
||||
expect(p.emparejarProvincia("Illes Balears")).toBe("Islas Baleares");
|
||||
expect(p.emparejarProvincia("Asturies")).toBe("Asturias");
|
||||
});
|
||||
|
||||
it("devuelve \"\" cuando no casa con ninguna provincia", () => {
|
||||
expect(p.emparejarProvincia("Île-de-France")).toBe("");
|
||||
expect(p.emparejarProvincia("")).toBe("");
|
||||
expect(p.emparejarProvincia("Lisboa")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("el contrato base no acepta ninguna entrada", () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
expect(p.detecta("41.38, 2.17")).toBe(false);
|
||||
expect(p.detecta("https://maps.google.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("extrae() de la base lanza \"sin implementar\" (método abstracto)", async () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
await expect(p.extrae("x")).rejects.toThrow("sin implementar");
|
||||
});
|
||||
|
||||
describe("fetchNominatim degrada errores a null", () => {
|
||||
it("devuelve null si fetch lanza (sin red)", async () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
|
||||
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("devuelve null si la respuesta HTTP no es ok", async () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false, status: 503 })));
|
||||
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("devuelve el JSON cuando todo va bien y manda cabeceras", async () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([{ lat: "1" }]) }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toEqual([{ lat: "1" }]);
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init.headers["Accept-Language"]).toBe("es");
|
||||
expect(init.headers["User-Agent"]).toBe("LocalesEspanoles/1.0");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ProveedorCoordenadas from "../../src/utils/ubicacion/proveedorCoordenadas.js";
|
||||
|
||||
describe("ProveedorCoordenadas", () => {
|
||||
const p = new ProveedorCoordenadas();
|
||||
|
||||
it("detecta pares con coma y punto decimal, signo y espacios", async () => {
|
||||
for (const [entrada, lat, lng] of [
|
||||
["41.3851, 2.1734", 41.3851, 2.1734],
|
||||
["41,3851,2,1734", 41.3851, 2.1734], // decimales españoles con coma
|
||||
["-3.7038, 40.4168", -3.7038, 40.4168], // orden invertido con signo
|
||||
[" 41.38 ; 2.17 ", 41.38, 2.17], // separador ; y espacios
|
||||
["41,3851 2,1734", 41.3851, 2.1734], // separado por espacio
|
||||
]) {
|
||||
expect(p.detecta(entrada), entrada).toBe(true);
|
||||
expect(await p.extrae(entrada), entrada).toEqual({ proveedor: "coordenadas", lat, lng });
|
||||
}
|
||||
});
|
||||
|
||||
it("latitud fuera de rango → null", async () => {
|
||||
expect(p.detecta("91.234, 2.17")).toBe(true); // sintácticamente es un par…
|
||||
expect(await p.extrae("91.234, 2.17")).toBeNull(); // …pero se descarta por rango
|
||||
expect(await p.extrae("41.38, 181.5")).toBeNull();
|
||||
});
|
||||
|
||||
it("no detecta textos ni URLs ni números sueltos", () => {
|
||||
expect(p.detecta("Calle Mayor 5, Madrid")).toBe(false);
|
||||
expect(p.detecta("https://maps.google.com/@41.38,2.17")).toBe(false);
|
||||
expect(p.detecta("41.3851")).toBe(false);
|
||||
expect(p.detecta("")).toBe(false);
|
||||
});
|
||||
|
||||
it("entrada no par → extrae null", async () => {
|
||||
expect(await p.extrae("hola mundo")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ProveedorGoogleMaps from "../../src/utils/ubicacion/proveedorGoogleMaps.js";
|
||||
|
||||
describe("ProveedorGoogleMaps", () => {
|
||||
const p = new ProveedorGoogleMaps();
|
||||
|
||||
it("detecta URLs de Google Maps (dominios y subdominios)", () => {
|
||||
expect(p.detecta("https://maps.google.com/@41.38,2.17")).toBe(true);
|
||||
expect(p.detecta("https://www.google.es/maps?q=41.38,2.17")).toBe(true);
|
||||
expect(p.detecta("https://maps.app.goo.gl/abc")).toBe(true);
|
||||
expect(p.detecta("https://waze.com/ul?ll=41.38,2.17")).toBe(false);
|
||||
expect(p.detecta("41.38, 2.17")).toBe(false);
|
||||
});
|
||||
|
||||
it("extrae del formato @lat,lng", async () => {
|
||||
expect(await p.extrae("https://www.google.com/maps/@41.3851,2.1734,15z"))
|
||||
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
|
||||
});
|
||||
|
||||
it("extrae del formato !3d…!4d… cuando no hay @", async () => {
|
||||
expect(await p.extrae("https://www.google.com/maps/place/Bar/data=!4m2!3m1!1s0x0:0x0?hl=es"))
|
||||
.toBeNull(); // sin coordenadas en absoluto
|
||||
expect(await p.extrae("https://www.google.com/maps/place/Bar/data=!3d41.3851!4d2.1734"))
|
||||
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
|
||||
});
|
||||
|
||||
it("extrae del formato ?q=lat,lng", async () => {
|
||||
expect(await p.extrae("https://www.google.com/maps?q=41.3851,-2.1734"))
|
||||
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: -2.1734 });
|
||||
});
|
||||
|
||||
it("prioriza @ sobre !3d!4d (comportamiento migrado de parsearEnlaceGoogleMaps)", async () => {
|
||||
const r = await p.extrae("https://www.google.com/maps/place/X/@40.4168,-3.7038,17z/data=!3d40.4178!4d-3.7028");
|
||||
expect(r).toEqual({ proveedor: "google-maps", lat: 40.4168, lng: -3.7038 });
|
||||
});
|
||||
|
||||
it("enlace acortado: detecta pero no puede extraer → null", async () => {
|
||||
expect(await p.extrae("https://maps.app.goo.gl/AbCdEf123")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ProveedorNominatim from "../../src/utils/ubicacion/proveedorNominatim.js";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("ProveedorNominatim", () => {
|
||||
const p = new ProveedorNominatim();
|
||||
|
||||
describe("detecta (fallback de texto)", () => {
|
||||
it("acepta direcciones y nombres con letras", () => {
|
||||
expect(p.detecta("Calle Mayor 5, Alcalá de Henares, Madrid")).toBe(true);
|
||||
expect(p.detecta("Bar El Olivo Sevilla")).toBe(true);
|
||||
});
|
||||
|
||||
it("rechaza URLs (las gestiona otro proveedor)", () => {
|
||||
expect(p.detecta("https://ejemplo.com/calle")).toBe(false);
|
||||
expect(p.detecta("www.ejemplo.com")).toBe(false);
|
||||
expect(p.detecta("https://maps.app.goo.gl/abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("rechaza textos demasiado cortos o sin letras", () => {
|
||||
expect(p.detecta("ab")).toBe(false);
|
||||
expect(p.detecta("")).toBe(false);
|
||||
expect(p.detecta("12345")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desdeResultado (normalización de provincia)", () => {
|
||||
it("normaliza provincia canónica insensible a acentos", () => {
|
||||
const r = p.desdeResultado({
|
||||
lat: "40.4", lon: "-3.7",
|
||||
display_name: "X, Madrid, España",
|
||||
address: { state: "Comunidad de Madrid" },
|
||||
namedetails: { name: "X" },
|
||||
});
|
||||
expect(r.provincia).toBe("Madrid");
|
||||
});
|
||||
|
||||
it("alias cooficiales: Bizkaia → Vizcaya, A Coruña → La Coruña", () => {
|
||||
const r = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { province: "Bizkaia" }, namedetails: {} });
|
||||
expect(r.provincia).toBe("Vizcaya");
|
||||
const r2 = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { city: "A Coruña" }, namedetails: {} });
|
||||
expect(r2.provincia).toBe("La Coruña");
|
||||
});
|
||||
|
||||
it("provincia fuera del listado → \"\" (selección manual)", () => {
|
||||
const r = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { state: "Île-de-France" }, namedetails: {} });
|
||||
expect(r.provincia).toBe("");
|
||||
});
|
||||
|
||||
it("resultado extensible: lat/lng/proveedor/nombre/direccion", () => {
|
||||
const r = p.desdeResultado({
|
||||
lat: "41.5", lon: "2.1",
|
||||
display_name: "X, Girona, España",
|
||||
address: { province: "Girona" },
|
||||
namedetails: { name: "X" },
|
||||
});
|
||||
expect(r).toEqual({ proveedor: "nominatim", lat: 41.5, lng: 2.1, nombre: "X", provincia: "Gerona", direccion: "X, Girona, España" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extrae (búsqueda por texto)", () => {
|
||||
it("devuelve el primer resultado normalizado", async () => {
|
||||
const fetchMock = vi.fn(() => ({
|
||||
ok: true,
|
||||
json: async () => [{
|
||||
lat: "40.4828632", lon: "-3.3652613",
|
||||
display_name: "5, Calle Mayor, Alcalá de Henares, España",
|
||||
address: { city: "Alcalá de Henares", state: "Comunidad de Madrid" },
|
||||
namedetails: { name: "Casa de la Panadería" },
|
||||
}],
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const r = await p.extrae("Calle Mayor 5, Alcalá de Henares");
|
||||
expect(r.proveedor).toBe("nominatim");
|
||||
expect(r.lat).toBeCloseTo(40.4828632, 6);
|
||||
expect(r.provincia).toBe("Madrid");
|
||||
expect(r.nombre).toBe("Casa de la Panadería");
|
||||
// la búsqueda acota a España
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(decodeURIComponent(String(url).replace(/\+/g, " "))).toContain("Calle Mayor 5, Alcalá de Henares, España");
|
||||
});
|
||||
|
||||
it("sin resultados → null", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => ({ ok: true, json: async () => [] })));
|
||||
expect(await p.extrae("calle inventada zzzz")).toBeNull();
|
||||
});
|
||||
|
||||
it("fallo de red → null (degradación controlada)", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
|
||||
expect(await p.extrae("Calle Mayor 5, Madrid")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ProveedorOSM from "../../src/utils/ubicacion/proveedorOSM.js";
|
||||
|
||||
describe("ProveedorOSM", () => {
|
||||
const p = new ProveedorOSM();
|
||||
|
||||
it("detecta URLs de openstreetmap.org", () => {
|
||||
expect(p.detecta("https://www.openstreetmap.org/#map=12/39.4699/-0.3763")).toBe(true);
|
||||
expect(p.detecta("https://openstreetmap.org/?mlat=41.38&mlon=2.17")).toBe(true);
|
||||
expect(p.detecta("https://www.google.com/maps")).toBe(false);
|
||||
});
|
||||
|
||||
it("extrae de mlat/mlon (enlace de marcador)", async () => {
|
||||
expect(await p.extrae("https://www.openstreetmap.org/?mlat=41.9794&mlon=2.8214"))
|
||||
.toEqual({ proveedor: "osm", lat: 41.9794, lng: 2.8214 });
|
||||
});
|
||||
|
||||
it("prioriza mlat/mlon sobre el fragmento #map=", async () => {
|
||||
const r = await p.extrae("https://www.openstreetmap.org/?mlat=41.9794&mlon=2.8214#map=15/39.4699/-0.3763");
|
||||
expect(r).toEqual({ proveedor: "osm", lat: 41.9794, lng: 2.8214 });
|
||||
});
|
||||
|
||||
it("extrae del fragmento #map=zoom/lat/lng cuando no hay mlat/mlon", async () => {
|
||||
expect(await p.extrae("https://www.openstreetmap.org/#map=12/39.4699/-0.3763"))
|
||||
.toEqual({ proveedor: "osm", lat: 39.4699, lng: -0.3763 });
|
||||
expect(await p.extrae("https://www.openstreet.org/#map=16.5/43.5321/-5.6556")) // zoom decimal
|
||||
.toEqual({ proveedor: "osm", lat: 43.5321, lng: -5.6556 });
|
||||
});
|
||||
|
||||
it("sin mlat/mlon ni #map= → null", async () => {
|
||||
expect(await p.extrae("https://www.openstreetmap.org/way/123456")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ProveedorWaze from "../../src/utils/ubicacion/proveedorWaze.js";
|
||||
|
||||
const respuestaNominatim = (resultados) => ({
|
||||
ok: true,
|
||||
json: async () => resultados,
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("ProveedorWaze", () => {
|
||||
const p = new ProveedorWaze();
|
||||
|
||||
it("detecta URLs de Waze", () => {
|
||||
expect(p.detecta("https://waze.com/ul?ll=41.38,2.17&navigate=yes")).toBe(true);
|
||||
expect(p.detecta("https://www.waze.com/live-map/")).toBe(true);
|
||||
expect(p.detecta("waze.to/abc")).toBe(true);
|
||||
expect(p.detecta("https://google.com/maps")).toBe(false);
|
||||
});
|
||||
|
||||
it("extrae de ll=lat,lng", async () => {
|
||||
expect(await p.extrae("https://waze.com/ul?ll=41.3851,2.1734&navigate=yes"))
|
||||
.toEqual({ proveedor: "waze", lat: 41.3851, lng: 2.1734 });
|
||||
// sin esquema https://
|
||||
expect(await p.extrae("waze.com/ul?ll=43.263,-2.935"))
|
||||
.toEqual({ proveedor: "waze", lat: 43.263, lng: -2.935 });
|
||||
});
|
||||
|
||||
it("ll malformado → null sin consultar Nominatim", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect(await p.extrae("https://waze.com/ul?ll=abc,def")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("q=texto delega en la búsqueda de Nominatim marcando proveedor waze", async () => {
|
||||
const fetchMock = vi.fn(() => respuestaNominatim([{
|
||||
lat: "40.4828632", lon: "-3.3652613",
|
||||
display_name: "5, Calle Mayor, Alcalá de Henares, Comunidad de Madrid, 28801, España",
|
||||
address: { city: "Alcalá de Henares", state: "Comunidad de Madrid" },
|
||||
namedetails: { name: "" },
|
||||
}]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const r = await p.extrae("https://waze.com/ul?q=Calle%20Mayor%205%2C%20Alcal%C3%A1%20de%20Henares");
|
||||
expect(r).toEqual({
|
||||
proveedor: "waze",
|
||||
lat: 40.4828632,
|
||||
lng: -3.3652613,
|
||||
nombre: "",
|
||||
provincia: "Madrid",
|
||||
direccion: "5, Calle Mayor, Alcalá de Henares, Comunidad de Madrid, 28801, España",
|
||||
});
|
||||
// la petición lleva countrycodes=es y las cabeceras de Nominatim
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toContain("countrycodes=es");
|
||||
expect(init.headers["User-Agent"]).toBe("LocalesEspanoles/1.0");
|
||||
});
|
||||
|
||||
it("q=texto sin resultados en Nominatim → null", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => respuestaNominatim([])));
|
||||
expect(await p.extrae("https://waze.com/ul?q=calle%20inexistente%20zzzz")).toBeNull();
|
||||
});
|
||||
|
||||
it("URL de Waze sin ll ni q → null", async () => {
|
||||
expect(await p.extrae("https://waze.com/live-map/")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ETIQUETAS_PROVEEDOR, PROVEEDORES, inferirDesdeCoords, resolverUbicacion } from "../../src/utils/ubicacion/index.js";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("resolverUbicacion", () => {
|
||||
it("respeta el orden de prioridad de PROVEEDORES (coordenadas → google → waze → osm → nominatim)", () => {
|
||||
expect(PROVEEDORES.map((p) => p.id)).toEqual(["coordenadas", "google-maps", "waze", "osm", "nominatim"]);
|
||||
});
|
||||
|
||||
it("resuelve un enlace de Google Maps sin tocar la red", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const r = await resolverUbicacion("https://maps.google.com/@41.3851,2.1734,15z");
|
||||
expect(r).toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resuelve coordenadas sueltas con el proveedor de coordenadas", async () => {
|
||||
const r = await resolverUbicacion("41.3851, 2.1734");
|
||||
expect(r.proveedor).toBe("coordenadas");
|
||||
});
|
||||
|
||||
it("hace fallback a Nominatim con texto de dirección (fetch simulado)", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => ({
|
||||
ok: true,
|
||||
json: async () => [{ lat: "40.1", lon: "-3.2", display_name: "X, Madrid", address: { state: "Comunidad de Madrid" }, namedetails: {} }],
|
||||
})));
|
||||
const r = await resolverUbicacion("Calle Mayor 5, Madrid");
|
||||
expect(r.proveedor).toBe("nominatim");
|
||||
expect(r.provincia).toBe("Madrid");
|
||||
});
|
||||
|
||||
it("un proveedor que lanza se degrada y continúa con el siguiente", async () => {
|
||||
// se inyecta temporalmente un proveedor roto con máxima prioridad
|
||||
const roto = { id: "roto", detecta: () => true, extrae: async () => { throw new Error("boom"); } };
|
||||
PROVEEDORES.unshift(roto);
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const r = await resolverUbicacion("https://maps.google.com/@41.3851,2.1734");
|
||||
expect(r.proveedor).toBe("google-maps");
|
||||
expect(error).toHaveBeenCalled();
|
||||
} finally {
|
||||
PROVEEDORES.shift();
|
||||
error.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("entrada no reconocida por nadie → null", async () => {
|
||||
expect(await resolverUbicacion("https://maps.app.goo.gl/abc123")).toBeNull();
|
||||
expect(await resolverUbicacion("")).toBeNull();
|
||||
expect(await resolverUbicacion(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferirDesdeCoords", () => {
|
||||
it("usa el reverse de Nominatim y normaliza la provincia", async () => {
|
||||
const fetchMock = vi.fn(() => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
lat: "40.4168", lon: "-3.7038",
|
||||
display_name: "Puerta del Sol, Madrid, España",
|
||||
address: { state: "Comunidad de Madrid" },
|
||||
namedetails: { name: "Puerta del Sol" },
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const r = await inferirDesdeCoords(40.4168, -3.7038);
|
||||
expect(r).toEqual({ nombre: "Puerta del Sol", provincia: "Madrid", direccion: "Puerta del Sol, Madrid, España" });
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toContain("/reverse");
|
||||
expect(String(url)).toContain("addressdetails=1");
|
||||
expect(String(url)).toContain("namedetails=1");
|
||||
});
|
||||
|
||||
it("fallo de red → null sin lanzar", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(await inferirDesdeCoords(1, 1)).toBeNull();
|
||||
error.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ETIQUETAS_PROVEEDOR", () => {
|
||||
it("cubre todos los ids registrados (para los chips de la UI)", () => {
|
||||
for (const p of PROVEEDORES) {
|
||||
expect(ETIQUETAS_PROVEEDOR[p.id], `falta etiqueta para ${p.id}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user