Files
portifolio-evandrodevbr/app/components/showcase/ProjectReel.test.tsx
T
Evandro Fonseca Junior c9a0b80656 perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
2026-10-14 03:32:23 -03:00

554 lines
19 KiB
TypeScript

import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { MOBILE_QUERY } from "@/lib/device";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
import { ProjectReel } from "./ProjectReel";
import { ShowcaseProvider } from "./ShowcaseProvider";
import { reelOffset, videoProgress } from "./showcase-math";
import { PINNED_QUERY } from "./useShowcaseMedia";
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
const PIN = 800;
const CONTAINER = 7000;
const SCROLLABLE = CONTAINER - PIN;
const HEADER = 60;
let reelTop = HEADER;
/** largura do celular da moldura: 0 simula uma moldura escondida por CSS */
let phoneWidth = 90;
const originalMatchMedia = window.matchMedia;
/** Tela de desktop por padrão (cabe a vitrine fixa); `phone` simula um celular. */
function mockViewport({ phone = false }: { phone?: boolean } = {}) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches:
(query === MOBILE_QUERY && phone) || (query === PINNED_QUERY && !phone),
media: query,
addEventListener() {},
removeEventListener() {},
}));
}
const projects = getFeaturedProjects(null);
const weights = [
"sev-modaintima",
"lavacao-047",
"nutriscan",
"sc-plus",
"as-servicos",
].map((id) => getShowcaseConfig(id)?.weight ?? 1);
const originalRect = Element.prototype.getBoundingClientRect;
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetHeight",
);
const originalOffsetWidth = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetWidth",
);
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get(this: HTMLElement) {
if (this.classList.contains("ds-reel")) return CONTAINER;
if (this.classList.contains("ds-reel-pin")) return PIN;
return 0;
},
});
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get(this: HTMLElement) {
return this.classList.contains("ds-reel-phone") ? phoneWidth : 0;
},
});
// 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"));
};
Element.prototype.getBoundingClientRect = function rect(this: Element) {
if (this.classList.contains("ds-reel"))
return {
top: reelTop,
bottom: reelTop + CONTAINER,
height: CONTAINER,
left: 0,
right: 1000,
width: 1000,
x: 0,
y: reelTop,
} as DOMRect;
return originalRect.call(this);
};
window.scrollTo = jest.fn();
});
afterAll(() => {
Element.prototype.getBoundingClientRect = originalRect;
if (originalOffsetHeight)
Object.defineProperty(
HTMLElement.prototype,
"offsetHeight",
originalOffsetHeight,
);
if (originalOffsetWidth)
Object.defineProperty(
HTMLElement.prototype,
"offsetWidth",
originalOffsetWidth,
);
});
beforeEach(() => {
jest.useFakeTimers();
reducedMotion = false;
reelTop = HEADER;
phoneWidth = 90;
mockViewport();
(window.scrollTo as jest.Mock).mockClear();
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
window.matchMedia = originalMatchMedia;
Object.defineProperty(navigator, "connection", {
configurable: true,
value: undefined,
});
});
function renderReel() {
const view = render(
<ShowcaseProvider projects={projects}>
<ProjectReel />
</ShowcaseProvider>,
);
act(() => {
jest.advanceTimersByTime(50);
});
return view;
}
/** jsdom não carrega mídia: cada <video> da vitrine ganha duração e prontidão para aceitar buscas. */
function readyVideos() {
for (const video of document.querySelectorAll("video")) {
Object.defineProperty(video, "readyState", {
configurable: true,
value: 4,
});
Object.defineProperty(video, "duration", { configurable: true, value: 30 });
let time = 0;
Object.defineProperty(video, "currentTime", {
configurable: true,
get: () => time,
set: (value: number) => {
time = value;
},
});
}
}
function scrollTo(progress: number) {
reelTop = HEADER - progress * SCROLLABLE;
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(1500);
});
}
const sources = () =>
[...document.querySelectorAll(".ds-reel-layer video")].map((video) =>
video.getAttribute("src"),
);
const activeLayer = () =>
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
describe("ProjectReel", () => {
it("falls back to a plain project list, nothing pinned, for users who prefer reduced motion", () => {
reducedMotion = true;
renderReel();
expect(
screen.queryByRole("region", { name: /Vitrine animada/ }),
).toBeNull();
expect(document.querySelector(".ds-reel")).toBeNull();
const list = screen.getByRole("region", { name: "Projetos de clientes" });
expect(within(list).getAllByRole("heading", { level: 3 })).toHaveLength(5);
expect(document.querySelector("video")).toBeNull();
});
it("is a touch carousel of posters, with nothing pinned and no video, on a phone", () => {
mockViewport({ phone: true });
renderReel();
expect(document.querySelector(".ds-reel")).toBeNull();
const list = screen.getByRole("region", { name: "Projetos de clientes" });
expect(list).not.toHaveAttribute("data-pending");
expect(document.querySelector("video")).toBeNull();
// o item chega a ~1 tela da janela (o IntersectionObserver do jest.setup avisa): só então vem o pôster
act(() => {
jest.advanceTimersByTime(50);
});
const posters = [...list.querySelectorAll("img")].map((image) =>
decodeURIComponent(image.getAttribute("src") ?? ""),
);
expect(posters.length).toBeGreaterThan(0);
for (const poster of posters) expect(poster).toContain("mobile.webp");
expect(list.querySelectorAll(".ds-phone")).toHaveLength(5);
expect(list.querySelector(".ds-bframe")).toBeNull();
});
it("still opens the enlarged video from the list, without any card in the page", () => {
mockViewport({ phone: true });
renderReel();
const item = screen
.getByRole("heading", { level: 3, name: "SEV Moda Íntima" })
.closest("li") as HTMLElement;
fireEvent.click(
within(item).getByRole("button", { name: /Ampliar vídeo/ }),
);
expect(
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
).toBeInTheDocument();
});
it("lists every project by name, starting on the first with its chapters", () => {
renderReel();
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
for (const name of [
"SEV Moda Íntima",
"Lavação 047",
"NutriScan",
"SC Plus Aquicultura",
"AS Serviços",
])
expect(within(nav).getByRole("button", { name })).toBeInTheDocument();
expect(
within(nav).getByRole("button", { name: "SEV Moda Íntima" }),
).toHaveAttribute("aria-current", "true");
expect(
within(nav).getByRole("list", { name: "Trechos de SEV Moda Íntima" }),
).toBeInTheDocument();
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
});
it("hides the layers that are not on screen from assistive technology and from the keyboard", () => {
renderReel();
const layers = [...document.querySelectorAll(".ds-reel-layer")];
expect(layers).toHaveLength(5);
for (const layer of layers.slice(1)) {
expect(layer).toHaveAttribute("aria-hidden", "true");
expect(layer).toHaveAttribute("inert");
}
expect(layers[0]).not.toHaveAttribute("inert");
});
it("moves to the next project and syncs the laptop and the phone to the same point of the site", () => {
renderReel();
readyVideos();
// meio do trecho da Lavação (segundo projeto)
const t = reelOffset(weights, 1, 0.5);
scrollTo(t);
const layer = activeLayer();
expect(layer).toHaveTextContent("Lavação 047");
const lavacao = getShowcaseConfig("lavacao-047");
if (!lavacao) throw new Error("config ausente");
const p = videoProgress(0.5);
const desktop = layer.querySelector(
".ds-reel-screen video",
) as HTMLVideoElement;
const phone = layer.querySelector(
".ds-phone-screen video",
) as HTMLVideoElement;
expect(desktop.currentTime).toBeCloseTo(p * 30, 1);
expect(phone.currentTime).toBeCloseTo(mobileFractionAt(lavacao, p) * 30, 1);
});
it("names the chapter on screen and counts them", () => {
renderReel();
readyVideos();
scrollTo(reelOffset(weights, 1, 0.5)); // Lavação, a meio caminho
const layer = activeLayer();
const lavacao = getShowcaseConfig("lavacao-047");
expect(lavacao).toBeDefined();
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
/^0\d\/03$/,
);
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
expect(labels).toContain(
layer.querySelector(".ds-reel-chapter strong")?.textContent,
);
});
it("holds the first and last frame at the ends of the pinned range", () => {
renderReel();
readyVideos();
scrollTo(0);
const first = document.querySelector(
'.ds-reel-layer[data-state="active"] .ds-reel-screen video',
) as HTMLVideoElement;
expect(first.currentTime).toBe(0);
scrollTo(1);
expect(activeLayer()).toHaveTextContent("AS Serviços");
const last = activeLayer().querySelector(
".ds-reel-screen video",
) as HTMLVideoElement;
expect(last.currentTime).toBeCloseTo(30, 1);
});
it("downloads nothing while the page is far from the reel", () => {
reelTop = 6000;
renderReel();
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(400);
});
expect(sources().every((src) => src === null)).toBe(true);
});
it("downloads video only for the project on screen (neighbours stay on the poster), and lets go of the rest", () => {
renderReel();
scrollTo(0);
act(() => {
jest.advanceTimersByTime(400);
});
let loaded = sources().filter(Boolean);
expect(
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
).toBe(true);
expect(
loaded.some((src) => src?.includes("sev-modaintima/scrub-mobile.mp4")),
).toBe(true);
// o vizinho (Lavação) e os demais ficam sem nenhum vídeo
expect(loaded.some((src) => src?.includes("lavacao-047"))).toBe(false);
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
expect(loaded.some((src) => src?.includes("as-servicos"))).toBe(false);
for (const video of document.querySelectorAll(".ds-reel-layer video"))
expect(video).toHaveAttribute("preload", "metadata");
// vai para o último projeto: o primeiro sai da janela e solta o arquivo
scrollTo(1);
act(() => {
jest.advanceTimersByTime(400);
});
loaded = sources().filter(Boolean);
expect(
loaded.some((src) => src?.includes("as-servicos/scrub-desktop.mp4")),
).toBe(true);
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
});
it("only downloads where the scroll settles, not every project it flies past", () => {
renderReel();
scrollTo(0);
// atravessa vários projetos em menos de 300 ms
for (const project of [1, 2, 3]) {
reelTop = HEADER - reelOffset(weights, project, 0.5) * SCROLLABLE;
act(() => {
window.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(60);
});
}
act(() => {
jest.advanceTimersByTime(400);
});
const loaded = sources().filter(Boolean);
expect(loaded.some((src) => src?.includes("sc-plus"))).toBe(true);
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false); // vizinho: só o pôster
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
});
it("does not download the phone video while the phone frame is not on screen", () => {
phoneWidth = 0;
renderReel();
scrollTo(0);
act(() => {
jest.advanceTimersByTime(400);
});
const loaded = sources().filter(Boolean);
expect(
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
).toBe(true);
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(false);
});
it("asks for posters only near the reel, and only for the project on screen and its neighbours", () => {
reelTop = 6000;
renderReel();
const background = (index: number) =>
(document.querySelectorAll(".ds-reel-screen")[index] as HTMLElement).style
.backgroundImage;
expect(background(0)).toBe("");
expect(document.querySelectorAll("video[poster]")).toHaveLength(0);
scrollTo(0); // chegou a menos de uma tela
expect(background(0)).toContain("sev-modaintima/desktop.webp");
expect(background(1)).toContain("lavacao-047/desktop.webp");
expect(background(3)).toBe("");
expect(background(4)).toBe("");
});
it("respects the data saver: no scroll video is downloaded", () => {
Object.defineProperty(navigator, "connection", {
configurable: true,
value: { saveData: true },
});
renderReel();
scrollTo(0);
act(() => {
jest.advanceTimersByTime(400);
});
expect(sources().every((src) => src === null)).toBe(true);
// o pôster continua lá
expect(
(document.querySelectorAll(".ds-reel-screen")[0] as HTMLElement).style
.backgroundImage,
).toContain("desktop.webp");
});
it("gives the server the project list (text and links, no media) and holds the pinned height before mounting", () => {
const html = renderToString(
<ShowcaseProvider projects={projects}>
<ProjectReel />
</ShowcaseProvider>,
);
// lugar reservado para a vitrine fixa: a página não muda de altura quando ela aparece (salto para #contact)
expect(html).toContain("ds-reel-placeholder");
expect(html).toContain("--reel-sum");
expect(html).toContain('aria-hidden="true"');
expect(html).not.toContain("<video");
expect(html).not.toContain("/videos/");
// sem JavaScript os projetos continuam ali, com o link do case
expect(html).toContain("ds-reel-list");
expect(html).toContain("data-pending");
expect(html).toContain("SEV Moda Íntima");
expect(html).toContain('href="/cases/lavacao-047"');
expect(html).not.toContain("Ampliar vídeo");
});
it("has an h2 before the project h3, in the pinned scene and in the list", () => {
const { unmount } = renderReel();
const region = screen.getByRole("region", { name: /Vitrine animada/ });
const order = [...region.querySelectorAll("h2, h3")].map(
(heading) => heading.tagName,
);
expect(order[0]).toBe("H2");
expect(order).toContain("H3");
unmount();
mockViewport({ phone: true });
renderReel();
const list = screen.getByRole("region", { name: "Projetos de clientes" });
expect(list.querySelector("h2, h3")?.tagName).toBe("H2");
});
it("limits each project to three chapters, in the index and in the caption", () => {
renderReel();
for (const layer of document.querySelectorAll(".ds-reel-layer"))
expect(
layer.querySelectorAll(".ds-reel-ticks li").length,
).toBeLessThanOrEqual(3);
const chapters = screen.getByRole("list", {
name: "Trechos de SEV Moda Íntima",
});
expect(within(chapters).getAllByRole("button")).toHaveLength(3);
});
it("shows the real domain in the address bar, or just the project name, never github.com", () => {
renderReel();
const bars = [...document.querySelectorAll(".ds-bframe-url")].map(
(bar) => bar.textContent,
);
expect(bars).toContain("sevmodaintima.com.br");
expect(bars).toContain("scplus.evandro.dev.br");
expect(bars).toContain("AS Serviços");
expect(document.body.innerHTML).not.toContain("github.com");
});
it("offers Ver case (outline), Abrir site (text link, only with a live site) and Ampliar vídeo, none of them lime", () => {
renderReel();
const layer = activeLayer();
const actions = layer.querySelector(".ds-reel-actions") as HTMLElement;
expect(
within(actions).getByRole("link", { name: /Ver case/ }),
).toHaveAttribute("href", "/cases/sev-modaintima");
const site = within(actions).getByRole("link", { name: /Abrir site/ });
expect(site).toHaveAttribute("href", "https://sevmodaintima.com.br");
expect(site).toHaveAttribute("target", "_blank");
expect(site).toHaveClass("ds-link-text");
expect(
within(actions).getByRole("button", { name: /Ampliar vídeo/ }),
).toHaveClass("ds-link-muted");
expect(document.querySelector(".ds-reel .ds-link-accent")).toBeNull();
// sem demoUrl não há "Abrir site"
const asServicos = [...document.querySelectorAll(".ds-reel-layer")].at(-1);
expect(asServicos).toBeDefined();
expect(
within(asServicos as HTMLElement).queryByRole("link", {
name: /Abrir site/,
hidden: true,
}),
).toBeNull();
});
it("opens the enlarged video from the pinned scene", () => {
renderReel();
fireEvent.click(
within(activeLayer()).getByRole("button", { name: /Ampliar vídeo/ }),
);
expect(
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
).toBeInTheDocument();
});
it("scrolls to the start of a project when its button is pressed", () => {
renderReel();
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
fireEvent.click(within(nav).getByRole("button", { name: /NutriScan/ }));
act(() => {
jest.advanceTimersByTime(1200);
});
const calls = (window.scrollTo as jest.Mock).mock.calls;
const last = calls[calls.length - 1][0] as { top: number };
const expected =
HEADER - HEADER + reelOffset(weights, 2, 0) * SCROLLABLE + 2;
expect(last.top).toBeCloseTo(expected, 0);
});
it("scrolls to a chapter inside the project when its link is pressed", () => {
renderReel();
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
fireEvent.click(within(nav).getByRole("button", { name: /As favoritas/ }));
act(() => {
jest.advanceTimersByTime(1200);
});
const calls = (window.scrollTo as jest.Mock).mock.calls;
const last = calls[calls.length - 1][0] as { top: number };
expect(last.top).toBeGreaterThan(reelOffset(weights, 0, 0) * SCROLLABLE);
expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE);
});
});