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.
332 lines
9.0 KiB
TypeScript
332 lines
9.0 KiB
TypeScript
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
|
import {
|
|
buildShowcaseItems,
|
|
ShowcaseProvider,
|
|
useShowcaseCard,
|
|
useShowcaseOpen,
|
|
} from "./ShowcaseProvider";
|
|
|
|
let reducedMotion = false;
|
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
|
useReducedMotion: () => reducedMotion,
|
|
}));
|
|
jest.mock("./ProjectLightbox", () => ({
|
|
ProjectLightbox: ({
|
|
index,
|
|
onClose,
|
|
}: {
|
|
index: number;
|
|
onClose: () => void;
|
|
}) => (
|
|
<div role="dialog" aria-label="ampliado">
|
|
aberto:{index}
|
|
<button type="button" onClick={onClose}>
|
|
fechar
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
type FakeObserver = {
|
|
callback: IntersectionObserverCallback;
|
|
targets: Set<Element>;
|
|
};
|
|
let observers: FakeObserver[] = [];
|
|
const OriginalObserver = global.IntersectionObserver;
|
|
|
|
class FakeIntersectionObserver {
|
|
private readonly state: FakeObserver;
|
|
constructor(callback: IntersectionObserverCallback) {
|
|
this.state = { callback, targets: new Set() };
|
|
observers.push(this.state);
|
|
}
|
|
observe(element: Element) {
|
|
this.state.targets.add(element);
|
|
}
|
|
unobserve(element: Element) {
|
|
this.state.targets.delete(element);
|
|
}
|
|
disconnect() {
|
|
this.state.targets.clear();
|
|
}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function setVisibility(element: Element, ratio: number) {
|
|
for (const observer of observers) {
|
|
if (!observer.targets.has(element)) continue;
|
|
act(() => {
|
|
observer.callback(
|
|
[
|
|
{
|
|
target: element,
|
|
intersectionRatio: ratio,
|
|
isIntersecting: ratio > 0,
|
|
} as IntersectionObserverEntry,
|
|
],
|
|
observer as unknown as IntersectionObserver,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
function place(element: Element, top: number) {
|
|
element.getBoundingClientRect = () =>
|
|
({
|
|
top,
|
|
height: 300,
|
|
bottom: top + 300,
|
|
left: 0,
|
|
right: 400,
|
|
width: 400,
|
|
x: 0,
|
|
y: top,
|
|
}) as DOMRect;
|
|
}
|
|
|
|
function flush() {
|
|
act(() => {
|
|
jest.advanceTimersByTime(40);
|
|
});
|
|
}
|
|
|
|
function Card({ id }: { id: string }) {
|
|
const { frameRef, active, open } = useShowcaseCard(id);
|
|
return (
|
|
<article className="ds-card">
|
|
<div ref={frameRef} data-testid={id} data-active={active} />
|
|
<button type="button" onClick={open}>
|
|
ampliar {id}
|
|
</button>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
const projects = getFeaturedProjects(null);
|
|
|
|
function setup() {
|
|
const view = render(
|
|
<ShowcaseProvider projects={projects}>
|
|
<Card id="sev-modaintima" />
|
|
<Card id="lavacao-047" />
|
|
<Card id="nutriscan" />
|
|
</ShowcaseProvider>,
|
|
);
|
|
const frames = {
|
|
sev: screen.getByTestId("sev-modaintima"),
|
|
lavacao: screen.getByTestId("lavacao-047"),
|
|
nutri: screen.getByTestId("nutriscan"),
|
|
};
|
|
// tela de 768px de altura (jsdom): o meio fica em 384
|
|
place(frames.sev, 100); // centro 250
|
|
place(frames.lavacao, 230); // centro 380
|
|
place(frames.nutri, 900); // fora da tela
|
|
return { view, frames };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
reducedMotion = false;
|
|
observers = [];
|
|
global.IntersectionObserver =
|
|
FakeIntersectionObserver as unknown as typeof IntersectionObserver;
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
global.IntersectionObserver = OriginalObserver;
|
|
});
|
|
|
|
const isActive = (element: Element) =>
|
|
element.getAttribute("data-active") === "true";
|
|
|
|
describe("ShowcaseProvider", () => {
|
|
it("lists only projects that have a recorded showcase, in showcase order", () => {
|
|
const items = buildShowcaseItems(projects);
|
|
expect(items.map((item) => item.config.id)).toEqual([
|
|
"sev-modaintima",
|
|
"lavacao-047",
|
|
"nutriscan",
|
|
"sc-plus",
|
|
"as-servicos",
|
|
]);
|
|
expect(
|
|
buildShowcaseItems(projects.filter((p) => p.id === "twins-lab")),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("serves the reel with only the client projects the home passes in, in showcase order", () => {
|
|
const clients = projects.filter((project) =>
|
|
["as-servicos", "sc-plus", "lavacao-047", "sev-modaintima"].includes(
|
|
project.id,
|
|
),
|
|
);
|
|
expect(buildShowcaseItems(clients).map((item) => item.config.id)).toEqual([
|
|
"sev-modaintima",
|
|
"lavacao-047",
|
|
"sc-plus",
|
|
"as-servicos",
|
|
]);
|
|
});
|
|
|
|
it("works with no cards at all: no per-scroll work, and the enlarged window still opens and navigates", () => {
|
|
const clients = projects.filter((project) =>
|
|
["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"].includes(
|
|
project.id,
|
|
),
|
|
);
|
|
function Opener() {
|
|
const open = useShowcaseOpen();
|
|
return (
|
|
<button type="button" onClick={() => open("sc-plus")}>
|
|
abrir sc-plus
|
|
</button>
|
|
);
|
|
}
|
|
const frames = jest.spyOn(window, "requestAnimationFrame");
|
|
render(
|
|
<ShowcaseProvider projects={clients}>
|
|
<Opener />
|
|
</ShowcaseProvider>,
|
|
);
|
|
flush();
|
|
frames.mockClear();
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
window.dispatchEvent(new Event("resize"));
|
|
});
|
|
flush();
|
|
expect(frames).not.toHaveBeenCalled();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "abrir sc-plus" }));
|
|
// posição 3 de 4 na lista que a home passou (o NutriScan não está nela)
|
|
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
|
|
"aberto:2",
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
frames.mockRestore();
|
|
});
|
|
|
|
it("plays nothing until a card is on screen, then the one closest to the middle", () => {
|
|
const { frames } = setup();
|
|
flush();
|
|
expect(Object.values(frames).some(isActive)).toBe(false);
|
|
|
|
setVisibility(frames.sev, 0.8);
|
|
setVisibility(frames.lavacao, 0.8);
|
|
flush();
|
|
expect(isActive(frames.lavacao)).toBe(true);
|
|
expect(isActive(frames.sev)).toBe(false);
|
|
});
|
|
|
|
it("does not count a card with less than a third on screen", () => {
|
|
const { frames } = setup();
|
|
setVisibility(frames.sev, 0.2);
|
|
setVisibility(frames.lavacao, 0.1);
|
|
flush();
|
|
expect(Object.values(frames).some(isActive)).toBe(false);
|
|
});
|
|
|
|
it("hands the video to the card under the mouse and takes it back when the mouse leaves", () => {
|
|
const { frames } = setup();
|
|
setVisibility(frames.sev, 0.8);
|
|
setVisibility(frames.lavacao, 0.8);
|
|
flush();
|
|
|
|
const sevCard = frames.sev.closest(".ds-card") as HTMLElement;
|
|
fireEvent.pointerEnter(sevCard, { pointerType: "mouse" });
|
|
flush();
|
|
expect(isActive(frames.sev)).toBe(true);
|
|
expect(isActive(frames.lavacao)).toBe(false);
|
|
|
|
fireEvent.pointerLeave(sevCard, { pointerType: "mouse" });
|
|
flush();
|
|
expect(isActive(frames.lavacao)).toBe(true);
|
|
});
|
|
|
|
it("ignores touch pointers: there is no hover on a phone", () => {
|
|
const { frames } = setup();
|
|
setVisibility(frames.sev, 0.8);
|
|
setVisibility(frames.lavacao, 0.8);
|
|
flush();
|
|
|
|
fireEvent.pointerEnter(frames.sev.closest(".ds-card") as HTMLElement, {
|
|
pointerType: "touch",
|
|
});
|
|
flush();
|
|
expect(isActive(frames.sev)).toBe(false);
|
|
expect(isActive(frames.lavacao)).toBe(true);
|
|
});
|
|
|
|
it("follows keyboard focus inside a card", () => {
|
|
const { frames } = setup();
|
|
setVisibility(frames.sev, 0.8);
|
|
setVisibility(frames.lavacao, 0.8);
|
|
flush();
|
|
|
|
fireEvent.focusIn(
|
|
screen.getByRole("button", { name: "ampliar sev-modaintima" }),
|
|
);
|
|
flush();
|
|
expect(isActive(frames.sev)).toBe(true);
|
|
});
|
|
|
|
it("re-evaluates on scroll", () => {
|
|
const { frames } = setup();
|
|
setVisibility(frames.sev, 0.8);
|
|
setVisibility(frames.lavacao, 0.8);
|
|
flush();
|
|
expect(isActive(frames.lavacao)).toBe(true);
|
|
|
|
place(frames.sev, 230);
|
|
place(frames.lavacao, 600);
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
});
|
|
flush();
|
|
expect(isActive(frames.sev)).toBe(true);
|
|
});
|
|
|
|
it("pauses every card while the enlarged window is open, and resumes after", () => {
|
|
const { frames } = setup();
|
|
setVisibility(frames.lavacao, 0.8);
|
|
flush();
|
|
expect(isActive(frames.lavacao)).toBe(true);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "ampliar lavacao-047" }),
|
|
);
|
|
flush();
|
|
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
|
|
"aberto:1",
|
|
);
|
|
expect(isActive(frames.lavacao)).toBe(false);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
|
|
flush();
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
expect(isActive(frames.lavacao)).toBe(true);
|
|
});
|
|
|
|
it("never plays a card for users who prefer reduced motion", () => {
|
|
reducedMotion = true;
|
|
const { frames } = setup();
|
|
setVisibility(frames.lavacao, 1);
|
|
flush();
|
|
expect(isActive(frames.lavacao)).toBe(false);
|
|
});
|
|
|
|
it("stops listening when it unmounts", () => {
|
|
const { view, frames } = setup();
|
|
setVisibility(frames.lavacao, 1);
|
|
view.unmount();
|
|
expect(observers.every((observer) => observer.targets.size === 0)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|