- vitest 4 traía vite 8 anidado con esbuild 0.28: el lockfile quedaba incompleto para npm ci y el npm 10.8.2 del runner fallaba con "Missing @esbuild/* from lock file" / EBADPLATFORM. vitest 3 usa el vite 5 del proyecto como peer: sin árbol anidado, esbuild solo 0.21.5 - jsdom 30 exige Node >=22.22 (el runner tiene 20.20): bajado a jsdom 26 - tests.yml sobre el runner real (self-hosted, Node del sistema) en vez de ubuntu-latest, que no existe como label - aserciones de max()/calc() tolerantes a la serialización de jsdom 26
105 lines
4.1 KiB
React
105 lines
4.1 KiB
React
// @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(<BannerMovil />);
|
|
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(<BannerMovil />);
|
|
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(<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();
|
|
});
|
|
|
|
it("con diálogo nativo, el botón de la tarjeta instala directamente", async () => {
|
|
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
|
estadoHook.canPromptInstall = true;
|
|
render(<BannerMovil />);
|
|
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(<BannerMovil />);
|
|
expect(screen.getByText(/Instálala desde el menú de tu navegador/i)).toBeTruthy();
|
|
});
|
|
});
|