315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
|
import { usePathname } from "next/navigation";
|
|
import { Header } from "./Header";
|
|
|
|
/**
|
|
* Testes de caminhos-base de Header (complemento de Header.test.tsx): a barra que escurece com a rolagem,
|
|
* as teclas do menu do celular que não o fecham e a rota ausente (fora do roteador).
|
|
*/
|
|
|
|
jest.mock("next/link", () => ({
|
|
__esModule: true,
|
|
default: ({
|
|
children,
|
|
prefetch,
|
|
...props
|
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
|
string,
|
|
unknown
|
|
>) => (
|
|
<a data-prefetch={String(prefetch)} {...props}>
|
|
{children}
|
|
</a>
|
|
),
|
|
}));
|
|
|
|
function setScrollY(value: number) {
|
|
Object.defineProperty(window, "scrollY", {
|
|
value,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
}
|
|
|
|
const header = () => screen.getByRole("banner");
|
|
/** Rolada: borda visível e fundo opaco (sem backdrop-blur: o filtro refazia a pintura a cada quadro da rolagem). */
|
|
const isDark = () => header().className.includes("border-white/12");
|
|
|
|
beforeEach(() => {
|
|
jest.mocked(usePathname).mockReturnValue("/");
|
|
setScrollY(0);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
setScrollY(0);
|
|
});
|
|
|
|
describe("Header: rolagem", () => {
|
|
it("path: page at the top -> transparent border and solid background", () => {
|
|
render(<Header />);
|
|
|
|
expect(isDark()).toBe(false);
|
|
expect(header()).toHaveClass("border-transparent");
|
|
});
|
|
|
|
it("path: scrolling down and back to the top -> bordered bar, then the plain one again, never with a backdrop blur", () => {
|
|
render(<Header />);
|
|
|
|
setScrollY(120);
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(isDark()).toBe(true);
|
|
expect(header()).not.toHaveClass("border-transparent");
|
|
expect(header().className).not.toContain("backdrop-blur");
|
|
|
|
// outro evento de rolagem sem mudar de lado: nada muda
|
|
setScrollY(300);
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(isDark()).toBe(true);
|
|
|
|
setScrollY(0);
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(isDark()).toBe(false);
|
|
});
|
|
|
|
it("path: the background is fully opaque at the top and scrolled (no 95% fill: the titles showed through)", () => {
|
|
render(<Header />);
|
|
|
|
expect(header().className).toContain("bg-[var(--ink)]");
|
|
expect(header().className).not.toContain("bg-[var(--ink)]/");
|
|
|
|
setScrollY(120);
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(header().className).toContain("bg-[var(--ink)]");
|
|
expect(header().className).not.toContain("bg-[var(--ink)]/");
|
|
});
|
|
|
|
it("path: page already scrolled when the header mounts -> starts with the bordered bar", () => {
|
|
setScrollY(500);
|
|
render(<Header />);
|
|
|
|
expect(isDark()).toBe(true);
|
|
});
|
|
|
|
it("path: unmount -> the scroll listener is removed", () => {
|
|
const remove = jest.spyOn(window, "removeEventListener");
|
|
const { unmount } = render(<Header />);
|
|
|
|
unmount();
|
|
|
|
expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function));
|
|
});
|
|
});
|
|
|
|
describe("Header: ícone do menu", () => {
|
|
it("path: open -> the outer bars move 10px (2px bar + 8px gap) to the middle and turn into a straight X; closed -> nothing moves", () => {
|
|
render(<Header />);
|
|
const button = screen.getByRole("button", { name: "Abrir menu" });
|
|
const bars = () =>
|
|
Array.from(button.querySelectorAll("span")) as HTMLElement[];
|
|
|
|
// 2 px de altura + 8 px de espaço entre as linhas = 10 px de centro a centro
|
|
expect(bars()[0]).toHaveClass("h-0.5");
|
|
expect(button).toHaveClass("gap-sp-2");
|
|
for (const bar of bars()) expect(bar.className).not.toMatch(/translate-y/);
|
|
|
|
fireEvent.click(button);
|
|
const open = bars();
|
|
expect(open).toHaveLength(3);
|
|
expect(open[0]).toHaveClass("translate-y-[10px]", "rotate-45");
|
|
expect(open[1]).toHaveClass("opacity-0");
|
|
expect(open[2]).toHaveClass("-translate-y-[10px]", "-rotate-45");
|
|
expect(open[0].className).not.toContain("6px");
|
|
expect(open[2].className).not.toContain("6px");
|
|
});
|
|
});
|
|
|
|
describe("Header: teclado no menu do celular", () => {
|
|
it("path: a key other than Escape inside the open menu -> the menu stays open and the key is not blocked", () => {
|
|
render(<Header />);
|
|
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
|
const menu = screen.getByRole("navigation", { name: "Navegação mobile" });
|
|
// Tab entre dois controles do meio da lista: segue o caminho normal (o foco só é preso nas pontas)
|
|
const middle = within(menu).getByRole("link", { name: "Serviços" });
|
|
act(() => middle.focus());
|
|
|
|
// fireEvent devolve false só quando o evento foi cancelado (preventDefault)
|
|
expect(fireEvent.keyDown(middle, { key: "Tab" })).toBe(true);
|
|
expect(fireEvent.keyDown(menu, { key: "Enter" })).toBe(true);
|
|
|
|
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
|
|
"aria-expanded",
|
|
"true",
|
|
);
|
|
expect(
|
|
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("path: Escape on the menu button while the menu is open -> closes it and keeps the focus on the button", () => {
|
|
render(<Header />);
|
|
const button = screen.getByRole("button", { name: "Abrir menu" });
|
|
fireEvent.click(button);
|
|
act(() => button.focus());
|
|
|
|
expect(fireEvent.keyDown(button, { key: "Escape" })).toBe(false);
|
|
|
|
const closed = screen.getByRole("button", { name: "Abrir menu" });
|
|
expect(closed).toHaveAttribute("aria-expanded", "false");
|
|
expect(closed).toHaveFocus();
|
|
expect(
|
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("path: Escape while the menu is already closed -> nothing opens", () => {
|
|
render(<Header />);
|
|
const button = screen.getByRole("button", { name: "Abrir menu" });
|
|
|
|
fireEvent.keyDown(button, { key: "Escape" });
|
|
|
|
expect(button).toHaveAttribute("aria-expanded", "false");
|
|
expect(
|
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Header: menu aberto, caminhos de borda do foco, do clique e da janela", () => {
|
|
/** O cabeçalho com o conteúdo da página que o menu aberto torna inerte (id do layout). */
|
|
function Page() {
|
|
return (
|
|
<>
|
|
<Header />
|
|
<div id="main-content" tabIndex={-1}>
|
|
<button type="button">Botão do conteúdo</button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
const openMenu = () =>
|
|
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
|
const content = () => document.getElementById("main-content") as HTMLElement;
|
|
|
|
afterEach(() => {
|
|
document.documentElement.style.removeProperty("overflow");
|
|
});
|
|
|
|
it("path: Tab with no control left to focus in the header -> the key is not blocked and the focus does not move", () => {
|
|
render(<Page />);
|
|
openMenu();
|
|
const banner = screen.getByRole("banner");
|
|
for (const button of banner.querySelectorAll("button"))
|
|
button.setAttribute("disabled", "");
|
|
for (const link of banner.querySelectorAll("a"))
|
|
link.removeAttribute("href");
|
|
act(() => document.body.focus());
|
|
|
|
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(true);
|
|
expect(
|
|
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true }),
|
|
).toBe(true);
|
|
|
|
expect(document.body).toHaveFocus();
|
|
});
|
|
|
|
it("path: click whose target is not an element (the document itself) -> the page stays locked", () => {
|
|
render(<Page />);
|
|
openMenu();
|
|
|
|
fireEvent.click(document);
|
|
|
|
expect(content()).toHaveAttribute("inert");
|
|
expect(document.documentElement.style.overflow).toBe("hidden");
|
|
});
|
|
|
|
it("path: click on something inside the header that is not a link -> the page stays locked", () => {
|
|
render(<Page />);
|
|
openMenu();
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
|
);
|
|
|
|
expect(content()).toHaveAttribute("inert");
|
|
expect(document.documentElement.style.overflow).toBe("hidden");
|
|
});
|
|
|
|
it("path: click on a link inside the header -> the page is released at once", () => {
|
|
render(<Page />);
|
|
openMenu();
|
|
|
|
fireEvent.click(
|
|
within(
|
|
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
|
).getByRole("link", { name: "Cases" }),
|
|
);
|
|
|
|
expect(content()).not.toHaveAttribute("inert");
|
|
expect(document.documentElement.style.overflow).toBe("");
|
|
});
|
|
|
|
it("path: no matchMedia at all in the browser -> the menu still opens and closes", () => {
|
|
const original = window.matchMedia;
|
|
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
|
try {
|
|
render(<Page />);
|
|
openMenu();
|
|
expect(
|
|
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
|
).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Fechar menu" }));
|
|
|
|
expect(
|
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
|
).toBeNull();
|
|
expect(content()).not.toHaveAttribute("inert");
|
|
} finally {
|
|
window.matchMedia = original;
|
|
}
|
|
});
|
|
|
|
it("path: matchMedia returns an object with no change listener (older browsers) -> the menu still opens and closes", () => {
|
|
const original = window.matchMedia;
|
|
window.matchMedia = jest.fn().mockReturnValue({
|
|
matches: false,
|
|
}) as unknown as typeof window.matchMedia;
|
|
try {
|
|
render(<Page />);
|
|
openMenu();
|
|
expect(
|
|
screen.getByRole("navigation", { name: "Navegação mobile" }),
|
|
).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Fechar menu" }));
|
|
|
|
expect(
|
|
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
|
).toBeNull();
|
|
expect(content()).not.toHaveAttribute("inert");
|
|
} finally {
|
|
window.matchMedia = original;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Header: rota ausente", () => {
|
|
it("path: usePathname gives null (outside the router) -> the header button goes to the home form", () => {
|
|
jest.mocked(usePathname).mockReturnValue(null as unknown as string);
|
|
render(<Header />);
|
|
|
|
expect(
|
|
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
|
|
).toHaveAttribute("href", "/#contact");
|
|
});
|
|
});
|