Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
456 lines
15 KiB
TypeScript
456 lines
15 KiB
TypeScript
import {
|
|
act,
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
within,
|
|
} from "@testing-library/react";
|
|
import { StrictMode } from "react";
|
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
|
import {
|
|
chapterFraction,
|
|
getShowcaseConfig,
|
|
mobileFractionAt,
|
|
} from "@/lib/project-showcase";
|
|
import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider";
|
|
|
|
let reducedMotion = false;
|
|
// a janela ampliada chega sob demanda (React.lazy): aqui entra já, para o teste não esperar
|
|
jest.mock("./ProjectLightboxLazy", () => ({
|
|
ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox,
|
|
}));
|
|
|
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
|
useReducedMotion: () => reducedMotion,
|
|
}));
|
|
|
|
function Card({ id }: { id: string }) {
|
|
const openProject = useShowcaseOpen();
|
|
return (
|
|
<article className="ds-card">
|
|
<button type="button" onClick={() => openProject(id)}>
|
|
ampliar {id}
|
|
</button>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
const projects = getFeaturedProjects(null);
|
|
const lavacao = getShowcaseConfig("lavacao-047");
|
|
|
|
function setup(id = "lavacao-047") {
|
|
render(
|
|
<ShowcaseProvider projects={projects}>
|
|
<Card id="sev-modaintima" />
|
|
<Card id="lavacao-047" />
|
|
<Card id="nutriscan" />
|
|
<Card id="as-servicos" />
|
|
</ShowcaseProvider>,
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: `ampliar ${id}` }));
|
|
return screen.getByRole("dialog");
|
|
}
|
|
|
|
/** jsdom não carrega mídia: o teste dá ao <video> uma duração e deixa o componente reagir como no navegador. */
|
|
function loadVideo(dialog: HTMLElement, duration = 30) {
|
|
const video = dialog.querySelector("video") as HTMLVideoElement;
|
|
Object.defineProperty(video, "duration", {
|
|
configurable: true,
|
|
value: duration,
|
|
});
|
|
let time = 0;
|
|
Object.defineProperty(video, "currentTime", {
|
|
configurable: true,
|
|
get: () => time,
|
|
set: (value: number) => {
|
|
time = value;
|
|
},
|
|
});
|
|
act(() => {
|
|
video.dispatchEvent(new Event("loadedmetadata"));
|
|
});
|
|
return video;
|
|
}
|
|
|
|
beforeAll(() => {
|
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
}));
|
|
// jsdom ainda não implementa o <dialog> modal
|
|
HTMLDialogElement.prototype.showModal = function showModal(
|
|
this: HTMLDialogElement,
|
|
) {
|
|
this.setAttribute("open", "");
|
|
};
|
|
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
|
|
this.removeAttribute("open");
|
|
this.dispatchEvent(new Event("close"));
|
|
};
|
|
});
|
|
|
|
beforeEach(() => {
|
|
reducedMotion = false;
|
|
jest
|
|
.spyOn(HTMLMediaElement.prototype, "play")
|
|
.mockImplementation(function play(this: HTMLMediaElement) {
|
|
// jsdom não toca nada: o teste mantém `paused` e os eventos como o navegador faria
|
|
Object.defineProperty(this, "paused", {
|
|
configurable: true,
|
|
value: false,
|
|
});
|
|
this.dispatchEvent(new Event("play"));
|
|
return Promise.resolve();
|
|
});
|
|
jest
|
|
.spyOn(HTMLMediaElement.prototype, "pause")
|
|
.mockImplementation(function pause(this: HTMLMediaElement) {
|
|
Object.defineProperty(this, "paused", {
|
|
configurable: true,
|
|
value: true,
|
|
});
|
|
this.dispatchEvent(new Event("pause"));
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
document.documentElement.classList.remove("ds-lb-open");
|
|
});
|
|
|
|
describe("ProjectLightbox", () => {
|
|
it("opens a modal dialog for the clicked project, locks page scroll and puts focus on close", () => {
|
|
const dialog = setup();
|
|
|
|
expect(dialog).toHaveAttribute("open");
|
|
expect(dialog).toHaveAccessibleName("Lavação 047");
|
|
expect(dialog).toHaveAttribute("data-lenis-prevent");
|
|
expect(document.documentElement).toHaveClass("ds-lb-open");
|
|
expect(
|
|
within(dialog).getByRole("button", { name: "Fechar" }),
|
|
).toHaveFocus();
|
|
expect(within(dialog).getByText("02 / 05")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the desktop video in a browser frame by default and the phone version on request", () => {
|
|
const dialog = setup();
|
|
const toggle = within(dialog).getByRole("group", {
|
|
name: "Versão do site",
|
|
});
|
|
|
|
expect(
|
|
within(toggle).getByRole("button", { name: "Computador" }),
|
|
).toHaveAttribute("aria-pressed", "true");
|
|
expect(dialog.querySelector(".ds-lb-frame")).not.toBeNull();
|
|
expect(dialog.querySelector('source[src$="desktop.webm"]')).not.toBeNull();
|
|
|
|
fireEvent.click(within(toggle).getByRole("button", { name: "Celular" }));
|
|
expect(
|
|
within(toggle).getByRole("button", { name: "Celular" }),
|
|
).toHaveAttribute("aria-pressed", "true");
|
|
expect(dialog.querySelector(".ds-lb-phone")).not.toBeNull();
|
|
expect(dialog.querySelector('source[src$="mobile.webm"]')).not.toBeNull();
|
|
});
|
|
|
|
it("keeps the same point of the site when switching devices", () => {
|
|
if (!lavacao) throw new Error("config ausente");
|
|
const dialog = setup();
|
|
const desktop = loadVideo(dialog, lavacao.durations.desktop);
|
|
|
|
// meio do vídeo desktop
|
|
desktop.currentTime = lavacao.durations.desktop * 0.5;
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
|
const phone = loadVideo(dialog, lavacao.durations.mobile);
|
|
|
|
expect(phone.currentTime).toBeCloseTo(
|
|
mobileFractionAt(lavacao, 0.5) * lavacao.durations.mobile,
|
|
5,
|
|
);
|
|
});
|
|
|
|
it("jumps to a chapter and marks it as current", () => {
|
|
if (!lavacao) throw new Error("config ausente");
|
|
const dialog = setup();
|
|
const video = loadVideo(dialog, lavacao.durations.desktop);
|
|
|
|
fireEvent.click(
|
|
within(dialog).getByRole("button", { name: /Do sujo ao brilho/ }),
|
|
);
|
|
|
|
expect(video.currentTime).toBeCloseTo(
|
|
chapterFraction(lavacao, 1, "desktop") * lavacao.durations.desktop,
|
|
5,
|
|
);
|
|
expect(
|
|
within(dialog).getByRole("button", { name: /Do sujo ao brilho/ }),
|
|
).toHaveAttribute("aria-current", "true");
|
|
});
|
|
|
|
it("toggles play and pause with an accessible name", () => {
|
|
const dialog = setup();
|
|
loadVideo(dialog);
|
|
|
|
const play = within(dialog).getByRole("button", { name: "Reproduzir" });
|
|
fireEvent.click(play);
|
|
expect(
|
|
within(dialog).getByRole("button", { name: "Pausar" }),
|
|
).toBeInTheDocument();
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Pausar" }));
|
|
expect(
|
|
within(dialog).getByRole("button", { name: "Reproduzir" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("seeks with the range slider", () => {
|
|
const dialog = setup();
|
|
const video = loadVideo(dialog, 30);
|
|
|
|
fireEvent.change(
|
|
within(dialog).getByRole("slider", { name: "Posição do vídeo" }),
|
|
{ target: { value: "500" } },
|
|
);
|
|
expect(video.currentTime).toBeCloseTo(15, 5);
|
|
});
|
|
|
|
it("moves between projects and disables the arrows at both ends", () => {
|
|
const dialog = setup("sev-modaintima");
|
|
expect(
|
|
within(dialog).getByRole("button", { name: "Projeto anterior" }),
|
|
).toBeDisabled();
|
|
|
|
fireEvent.click(
|
|
within(dialog).getByRole("button", { name: "Próximo projeto" }),
|
|
);
|
|
expect(screen.getByRole("dialog")).toHaveAccessibleName("Lavação 047");
|
|
|
|
fireEvent.keyDown(screen.getByRole("dialog"), { key: "ArrowLeft" });
|
|
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
|
|
});
|
|
|
|
it("offers the case and, when the site exists, a text link to it in a new tab; never the code", () => {
|
|
const dialog = setup("lavacao-047");
|
|
expect(
|
|
within(dialog).getByRole("link", { name: /Ver case/ }),
|
|
).toHaveAttribute("href", "/cases/lavacao-047");
|
|
const site = within(dialog).getByRole("link", { name: /Abrir site/ });
|
|
expect(site).toHaveAttribute(
|
|
"href",
|
|
"https://landing-page-lavacao047.vercel.app",
|
|
);
|
|
expect(site).toHaveAttribute("target", "_blank");
|
|
expect(site).toHaveClass("ds-link-text");
|
|
expect(dialog.querySelector(".ds-link-accent")).toBeNull();
|
|
expect(within(dialog).queryByRole("link", { name: /código/i })).toBeNull();
|
|
expect(dialog.innerHTML).not.toContain("github.com");
|
|
});
|
|
|
|
it("has no site link, and an address bar with only the project name, for a project without a live site", () => {
|
|
const dialog = setup("as-servicos");
|
|
expect(
|
|
within(dialog).queryByRole("link", { name: /Abrir site/ }),
|
|
).toBeNull();
|
|
expect(
|
|
within(dialog).getByRole("link", { name: /Ver case/ }),
|
|
).toHaveAttribute("href", "/cases/as-servicos");
|
|
expect(dialog.querySelector(".ds-bframe-url")?.textContent).toBe(
|
|
"AS Serviços",
|
|
);
|
|
});
|
|
|
|
it("opens on the phone version below 768 px", () => {
|
|
const original = window.matchMedia;
|
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
|
matches: query === "(max-width: 767px)",
|
|
media: query,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
}));
|
|
try {
|
|
const dialog = setup();
|
|
expect(
|
|
within(dialog).getByRole("button", { name: "Celular" }),
|
|
).toHaveAttribute("aria-pressed", "true");
|
|
expect(dialog.querySelector(".ds-lb-phone")).not.toBeNull();
|
|
} finally {
|
|
window.matchMedia = original;
|
|
}
|
|
});
|
|
|
|
it("does not autoplay, nor preload the whole file, with the data saver on", () => {
|
|
Object.defineProperty(navigator, "connection", {
|
|
configurable: true,
|
|
value: { saveData: true },
|
|
});
|
|
try {
|
|
const dialog = setup();
|
|
const video = dialog.querySelector("video") as HTMLVideoElement;
|
|
expect(video).not.toHaveAttribute("autoplay");
|
|
expect(video).toHaveAttribute("preload", "metadata");
|
|
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
|
} finally {
|
|
Object.defineProperty(navigator, "connection", {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("closes with the close button, with the backdrop or with Esc, and unlocks the page", () => {
|
|
setup();
|
|
fireEvent.click(screen.getByRole("button", { name: "Fechar" }));
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
expect(document.documentElement).not.toHaveClass("ds-lb-open");
|
|
|
|
cleanup();
|
|
const dialog = setup();
|
|
fireEvent.pointerDown(dialog);
|
|
fireEvent.click(dialog); // clique no fundo (o próprio <dialog>)
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
|
|
cleanup();
|
|
const again = setup();
|
|
act(() => {
|
|
again.removeAttribute("open"); // o Esc nativo fecha o <dialog> e só então dispara `close`
|
|
again.dispatchEvent(new Event("close"));
|
|
});
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
expect(document.documentElement).not.toHaveClass("ds-lb-open");
|
|
});
|
|
|
|
it("does not autoplay for users who prefer reduced motion", () => {
|
|
reducedMotion = true;
|
|
const dialog = setup();
|
|
expect(dialog.querySelector("video")).not.toHaveAttribute("autoplay");
|
|
});
|
|
});
|
|
|
|
describe("ProjectLightbox: revisão", () => {
|
|
it("survives React StrictMode in development: the double effect must not close the window it just opened", () => {
|
|
// o `close` real é assíncrono: o teste reproduz isso para pegar o evento atrasado
|
|
HTMLDialogElement.prototype.close = function close(
|
|
this: HTMLDialogElement,
|
|
) {
|
|
this.removeAttribute("open");
|
|
queueMicrotask(() => this.dispatchEvent(new Event("close")));
|
|
};
|
|
jest.useFakeTimers();
|
|
try {
|
|
render(
|
|
<StrictMode>
|
|
<ShowcaseProvider projects={projects}>
|
|
<Card id="lavacao-047" />
|
|
</ShowcaseProvider>
|
|
</StrictMode>,
|
|
);
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "ampliar lavacao-047" }),
|
|
);
|
|
act(() => {
|
|
jest.advanceTimersByTime(50);
|
|
});
|
|
expect(screen.getByRole("dialog")).toHaveAttribute("open");
|
|
} finally {
|
|
jest.useRealTimers();
|
|
HTMLDialogElement.prototype.close = function close(
|
|
this: HTMLDialogElement,
|
|
) {
|
|
this.removeAttribute("open");
|
|
this.dispatchEvent(new Event("close"));
|
|
};
|
|
}
|
|
});
|
|
|
|
it("ignores a late close event from a dialog that is open again", () => {
|
|
const dialog = setup();
|
|
act(() => {
|
|
dialog.dispatchEvent(new Event("close"));
|
|
});
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not close when a press starts inside the panel and is released on the backdrop", () => {
|
|
const dialog = setup();
|
|
fireEvent.pointerDown(
|
|
within(dialog).getByRole("slider", { name: "Posição do vídeo" }),
|
|
);
|
|
fireEvent.click(dialog);
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
});
|
|
|
|
it("stays paused after switching device when the visitor had paused", () => {
|
|
const dialog = setup();
|
|
loadVideo(dialog);
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Reproduzir" }));
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Pausar" }));
|
|
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
|
expect(dialog.querySelector("video")).not.toHaveAttribute("autoplay");
|
|
expect(
|
|
within(dialog).getByRole("button", { name: "Reproduzir" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps playing after switching device when the video was playing", () => {
|
|
const dialog = setup();
|
|
loadVideo(dialog);
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Reproduzir" }));
|
|
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
|
expect(dialog.querySelector("video")).toHaveAttribute("autoplay");
|
|
});
|
|
|
|
it("keeps the position when the visitor flips the device twice before the new video loads", () => {
|
|
if (!lavacao) throw new Error("config ausente");
|
|
const dialog = setup();
|
|
const desktop = loadVideo(dialog, lavacao.durations.desktop);
|
|
desktop.currentTime = lavacao.durations.desktop * 0.5;
|
|
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
|
// o vídeo do celular ainda não carregou (sem duração): volta de novo para o computador
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Computador" }));
|
|
const back = loadVideo(dialog, lavacao.durations.desktop);
|
|
|
|
expect(back.currentTime / lavacao.durations.desktop).toBeCloseTo(0.5, 1);
|
|
});
|
|
|
|
it("moves focus to the opposite arrow when the pressed one reaches its end", () => {
|
|
const dialog = setup("nutriscan");
|
|
// anterior, anterior: chega ao primeiro e o botão "Anterior" fica desabilitado
|
|
let current = within(dialog).getByRole("button", {
|
|
name: "Projeto anterior",
|
|
});
|
|
current.focus();
|
|
fireEvent.click(current);
|
|
current = within(screen.getByRole("dialog")).getByRole("button", {
|
|
name: "Projeto anterior",
|
|
});
|
|
current.focus();
|
|
fireEvent.click(current);
|
|
|
|
const after = within(screen.getByRole("dialog"));
|
|
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
|
|
expect(
|
|
after.getByRole("button", { name: "Projeto anterior" }),
|
|
).toBeDisabled();
|
|
expect(
|
|
after.getByRole("button", { name: "Próximo projeto" }),
|
|
).toHaveFocus();
|
|
});
|
|
|
|
it("announces which project is on screen", () => {
|
|
const dialog = setup("nutriscan");
|
|
expect(dialog.querySelector("[aria-live='polite']")).toHaveTextContent(
|
|
"NutriScan, projeto 3 de 5",
|
|
);
|
|
});
|
|
|
|
it("ignores arrow keys combined with modifiers, which belong to the browser", () => {
|
|
const dialog = setup("lavacao-047");
|
|
fireEvent.keyDown(dialog, { key: "ArrowRight", altKey: true });
|
|
expect(screen.getByRole("dialog")).toHaveAccessibleName("Lavação 047");
|
|
});
|
|
});
|