import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { resetNavigation, setNavigation } from "@/__mocks__/next/navigation";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import FloatingWhatsApp from "./FloatingWhatsApp";
import GoogleAnalytics from "./GoogleAnalytics";
/**
* Testes de caminhos-base de FloatingWhatsApp (complemento de FloatingWhatsApp.test.tsx):
* cada convite (botão do hero, seção de contato) sozinho na página, relatórios parciais do observador,
* desconexão ao desmontar e a convivência com a barra de cookies.
* A regra "só abaixo de 768 px" é CSS (md:hidden); o jsdom não avalia media queries, então só a classe é conferida.
*/
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => ,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
type Entry = Pick;
const instances: FakeObserver[] = [];
const originalObserver = global.IntersectionObserver;
class FakeObserver {
observed: Element[] = [];
disconnect = jest.fn();
constructor(private callback: IntersectionObserverCallback) {
instances.push(this);
}
observe = jest.fn((target: Element) => {
this.observed.push(target);
});
unobserve = jest.fn();
takeRecords = () => [];
report(entries: Entry[]) {
act(() =>
this.callback(entries as IntersectionObserverEntry[], this as never),
);
}
}
const hero = () => document.getElementById("hero-contact-cta") as Element;
const contact = () => document.getElementById("contact") as Element;
const floatingLink = () =>
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
beforeEach(() => {
resetNavigation();
instances.length = 0;
global.IntersectionObserver =
FakeObserver as unknown as typeof IntersectionObserver;
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
window.gtag = jest.fn();
});
afterEach(() => {
global.IntersectionObserver = originalObserver;
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
document.documentElement.style.removeProperty("--cookie-bar-height");
document.documentElement.removeAttribute("data-cookie-bar");
});
function renderBoth() {
return render(
<>
Conversar
>,
);
}
describe("FloatingWhatsApp: convites presentes na página", () => {
it("path: only the hero button exists -> observes just it and follows its visibility", () => {
render(
<>
Conversar sobre meu projeto
>,
);
expect(instances).toHaveLength(1);
expect(instances[0].observed).toEqual([hero()]);
expect(floatingLink()).toBeNull();
instances[0].report([{ target: hero(), isIntersecting: true }]);
expect(floatingLink()).toBeNull();
instances[0].report([{ target: hero(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: only the contact section exists -> observes just it and follows its visibility", () => {
render(
<>
>,
);
expect(instances[0].observed).toEqual([contact()]);
instances[0].report([{ target: contact(), isIntersecting: true }]);
expect(floatingLink()).toBeNull();
instances[0].report([{ target: contact(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: neither invite exists -> no observer is created and the button shows at once", () => {
render();
expect(instances).toHaveLength(0);
expect(floatingLink()).toBeInTheDocument();
});
it("path: both invites exist -> both are observed", () => {
renderBoth();
expect(instances[0].observed).toEqual([hero(), contact()]);
});
});
describe("FloatingWhatsApp: relatórios do observador", () => {
it("path: a later report about one invite keeps what was said about the other", () => {
renderBoth();
const [observer] = instances;
observer.report([
{ target: hero(), isIntersecting: true },
{ target: contact(), isIntersecting: false },
]);
expect(floatingLink()).toBeNull();
// só o contato é reportado de novo; o hero continua à vista
observer.report([{ target: contact(), isIntersecting: false }]);
expect(floatingLink()).toBeNull();
observer.report([{ target: hero(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: both invites on screen at once -> hidden until both leave", () => {
renderBoth();
const [observer] = instances;
observer.report([
{ target: hero(), isIntersecting: true },
{ target: contact(), isIntersecting: true },
]);
expect(floatingLink()).toBeNull();
observer.report([{ target: hero(), isIntersecting: false }]);
expect(floatingLink()).toBeNull();
observer.report([{ target: contact(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: an empty report -> nothing is visible, so the button shows", () => {
renderBoth();
instances[0].report([]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: unmount -> the observer is disconnected", () => {
const { unmount } = renderBoth();
expect(instances[0].disconnect).not.toHaveBeenCalled();
unmount();
expect(instances[0].disconnect).toHaveBeenCalledTimes(1);
});
});
describe("FloatingWhatsApp: conteúdo e cliques", () => {
it("path: the shortcut is CSS-limited to small screens and sized as a 56px target", () => {
render();
const link = floatingLink();
expect(link).toHaveClass("h-14", "w-14");
expect(link?.parentElement).toHaveClass("md:hidden", "fixed", "right-sp-4");
});
it("path: click on the home reports the home subject as the service (the central navigation mock opens on '/')", () => {
render();
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ service: HOME_SUBJECT, placement: "floating" }),
);
});
it("path: click on a page without a subject reports 'Contato' as the service", () => {
setNavigation({ pathname: "/privacidade" });
render();
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ service: "Contato", placement: "floating" }),
);
});
});
describe("FloatingWhatsApp com a barra de cookies aberta", () => {
it("path: the bar marks while open, which hides the button by CSS, and drops the mark once a choice is made", async () => {
const user = userEvent.setup();
localStorage.clear();
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: () => 72,
});
try {
render(
<>
>,
);
const refuse = await screen.findByRole("button", { name: "Recusar" });
// a barra publica a altura (o menu do celular a usa) e marca o ; o botão flutuante some por CSS com a marca
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("72px");
expect(document.documentElement).toHaveAttribute(
"data-cookie-bar",
"open",
);
expect(floatingLink()?.parentElement).toHaveClass(
"[[data-cookie-bar=open]_&]:hidden",
);
await user.click(refuse);
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("");
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
expect(floatingLink()).toBeInTheDocument();
} finally {
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
}
});
});