feat(propuesta): pasarela paso a paso con autodetección de ubicación y FAB
deploy-branch / deploy (push) Failing after 6s
deploy-branch / teardown (push) Skipped

- 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:
2026-08-16 01:35:02 +02:00
parent d48b74cbd4
commit 003b1e209f
53 changed files with 4896 additions and 253 deletions
+127
View File
@@ -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
});
});