// @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: () => {}, }); // En pantallas anchas la tarjeta acompaña a la columna de 720px (mismo borde // que el contenido); en estrechas queda a 16px del borde de la ventana. // (la serialización exacta de max()/calc() varía entre versiones de jsdom, // así que se comprueba por contenido y no por sintaxis exacta) function assertAlineadoAlCuerpo(styleAttr) { const left = (styleAttr.match(/left:\s*([^;]+)/) || [])[1]; expect(left, "debe usar left con max()").toMatch(/max\(/); expect(left).toContain("100vw"); expect(left).toContain("720px"); expect(left).toContain("16px"); } function contenedorFijo() { const fijos = [...document.querySelectorAll("div")].filter((d) => d.style.position === "fixed"); expect(fijos.length).toBe(1); return fijos[0]; } 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 (PC y móvil): abajo a la izquierda, alineado al cuerpo", () => { it("en PC: tarjeta abajo a la izquierda alineada con el borde del cuerpo central, más ancha", () => { vi.stubGlobal("matchMedia", matchMediaMock(false)); render(); expect(screen.getByText("App para móvil")).toBeTruthy(); const contenedor = contenedorFijo(); expect(contenedor.style.bottom).toBe("0px"); assertAlineadoAlCuerpo(contenedor.getAttribute("style")); expect(contenedor.style.right).toBe(""); // nunca a la derecha, ahí vive el FAB // en PC la tarjeta es más grande const tarjeta = screen.getByRole("button", { name: "Cerrar aviso de instalación" }).parentElement; expect(tarjeta.style.width).toBe("320px"); }); it("en móvil: tarjeta abajo a la izquierda, a 16px en ventana estrecha, más compacta", () => { vi.stubGlobal("matchMedia", matchMediaMock(true)); render(); const contenedor = contenedorFijo(); // misma fórmula: con vw < 720px el navegador la evalúa a 16px assertAlineadoAlCuerpo(contenedor.getAttribute("style")); expect(contenedor.style.bottom).toBe("0px"); expect(contenedor.style.right).toBe(""); const tarjeta = screen.getByRole("button", { name: "Cerrar aviso de instalación" }).parentElement; expect(tarjeta.style.width).toBe("280px"); }); it("el ✕ aplica la regla de 14 días y oculta el aviso", () => { vi.stubGlobal("matchMedia", matchMediaMock(false)); render(); 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(); }); it("con diálogo nativo, el botón de la tarjeta instala directamente", async () => { vi.stubGlobal("matchMedia", matchMediaMock(false)); estadoHook.canPromptInstall = true; render(); fireEvent.click(screen.getByRole("button", { name: /instalar app/i })); expect(estadoHook.promptInstall).toHaveBeenCalledTimes(1); }); it("sin diálogo nativo muestra las instrucciones del navegador (no iOS)", () => { vi.stubGlobal("matchMedia", matchMediaMock(false)); render(); expect(screen.getByText(/Instálala desde el menú de tu navegador/i)).toBeTruthy(); }); });