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
+112
View File
@@ -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();
});
});