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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user