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
+84
View File
@@ -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();
});
});
});
+36
View File
@@ -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();
});
});
+40
View File
@@ -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();
});
});
+94
View File
@@ -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();
});
});
});
+33
View File
@@ -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();
});
});
+67
View File
@@ -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();
});
});
+90
View File
@@ -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();
}
});
});