Files
portifolio-evandrodevbr/app/components/showcase/ProjectLightbox.test.tsx
T
evandro bcdf0099c5
CI / audit (push) Failing after 5m27s
CI / e2e (push) Failing after 11m23s
CI / verify (push) Successful in 11m31s
CI / deploy (push) Successful in 1m19s
feat(cases): adiciona o case Julia Beauty com vídeo de rolagem
2026-10-24 04:50:58 -03:00

503 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 / 06")).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("shows the duration when the video already has its metadata at opening (loadedmetadata never fires again)", () => {
jest
.spyOn(HTMLMediaElement.prototype, "readyState", "get")
.mockReturnValue(1);
jest
.spyOn(HTMLMediaElement.prototype, "duration", "get")
.mockReturnValue(75);
const dialog = setup();
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe(
"0:00 / 1:15",
);
});
it("shows –:– / –:– until the duration is there, then reads it from the playback tick", () => {
const frames: FrameRequestCallback[] = [];
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
frames.push(cb);
return frames.length;
});
const dialog = setup();
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe("–:– / –:–");
// o leitor de tela também não ouve "0:00 de 0:00"
expect(dialog.querySelector(".ds-lb-range")).toHaveAttribute(
"aria-valuetext",
"duração ainda não carregada",
);
const video = dialog.querySelector("video") as HTMLVideoElement;
Object.defineProperty(video, "duration", {
configurable: true,
value: 42,
});
// sem evento algum: quem lê é o marcador, quadro a quadro, enquanto o vídeo toca
act(() => {
video.dispatchEvent(new Event("play"));
});
act(() => {
for (const frame of frames.splice(0)) frame(0);
});
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe(
"0:00 / 0:42",
);
});
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 6",
);
});
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");
});
});