Files
portifolio-evandrodevbr/app/components/showcase/ProjectLightbox.test.tsx
T
Evandro Fonseca JuniorandClaude Sonnet 5.5 a1a1e91135 feat(showcase): moldura de navegador, um video por vez e video ampliado nos cards
- cards com moldura de navegador (endereco real) e brilho na cor do site no card que toca
- um video por vez: o do card sob o mouse/foco, ou o mais perto do centro da tela; nada baixa
  antes de tocar, e um botao pausa os videos que se mexem sozinhos
- janela ampliada em <dialog> nativo: troca computador/celular mantendo o ponto do site,
  capitulos clicaveis, controles proprios, setas entre projetos e foco devolvido ao fechar
- dados e capitulos de cada projeto em lib/project-showcase.ts (substitui project-videos.ts)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 16:36:53 -03:00

394 lines
13 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, useShowcaseCard } from "./ShowcaseProvider";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
function Card({ id }: { id: string }) {
const { frameRef, open } = useShowcaseCard(id);
return (
<article className="ds-card">
<div ref={frameRef} />
<button type="button" onClick={open}>
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, 2, "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 site and the code when they exist", () => {
const dialog = setup("lavacao-047");
expect(
within(dialog).getByRole("link", { name: /Abrir projeto/ }),
).toHaveAttribute("href", "https://landing-page-lavacao047.vercel.app");
expect(
within(dialog).queryByRole("link", { name: /Ver codigo/ }),
).toBeNull();
});
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");
});
});