503 lines
17 KiB
TypeScript
503 lines
17 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 / 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");
|
||
});
|
||
});
|