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.
This commit is contained in:
1 parent
8d07b8c6bb
commit
c9a0b80656
48 files changed
+7210
-1241
No files matched your search
@@ -0,0 +1,557 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import {
|
||||
buildShowcaseItems,
|
||||
ShowcaseProvider,
|
||||
useShowcaseCard,
|
||||
useShowcaseItems,
|
||||
useShowcaseOpen,
|
||||
} from "./ShowcaseProvider";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ShowcaseProvider (complemento de ShowcaseProvider.test.tsx): register() sem
|
||||
* IntersectionObserver, limiar de visibilidade, quem devolve o mouse, desmontagem do card em destaque, foco
|
||||
* que sai para dentro ou para fora do card, aba oculta, pausa do usuário e a janela ampliada.
|
||||
*/
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
jest.mock("./ProjectLightbox", () => ({
|
||||
ProjectLightbox: ({
|
||||
index,
|
||||
items,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: {
|
||||
index: number;
|
||||
items: unknown[];
|
||||
onClose: () => void;
|
||||
onNavigate: (index: number) => void;
|
||||
}) => (
|
||||
<div role="dialog" aria-label="ampliado">
|
||||
aberto:{index} de {items.length}
|
||||
<button type="button" onClick={onClose}>
|
||||
fechar
|
||||
</button>
|
||||
<button type="button" onClick={() => onNavigate(0)}>
|
||||
ir para o primeiro
|
||||
</button>
|
||||
<button type="button" onClick={() => onNavigate(99)}>
|
||||
ir para um que não existe
|
||||
</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 report(target: Element, ratio: number) {
|
||||
act(() => {
|
||||
for (const observer of observers) {
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
target,
|
||||
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, toggleAutoplay, userPaused } =
|
||||
useShowcaseCard(id);
|
||||
return (
|
||||
<article className="ds-card">
|
||||
<div ref={frameRef} data-testid={id} data-active={active} />
|
||||
<button type="button" onClick={open}>
|
||||
ampliar {id}
|
||||
</button>
|
||||
<button type="button" onClick={toggleAutoplay}>
|
||||
pausa {id} {String(userPaused)}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
|
||||
function Cards({ ids }: { ids: string[] }) {
|
||||
return (
|
||||
<ShowcaseProvider projects={projects}>
|
||||
{ids.map((id) => (
|
||||
<Card key={id} id={id} />
|
||||
))}
|
||||
</ShowcaseProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function setup(ids = ["sev-modaintima", "lavacao-047"]) {
|
||||
const view = render(<Cards ids={ids} />);
|
||||
const frame = (id: string) => screen.getByTestId(id);
|
||||
// tela de 768px de altura no jsdom: o meio fica em 384
|
||||
if (ids.includes("sev-modaintima")) place(frame("sev-modaintima"), 100); // centro 250
|
||||
if (ids.includes("lavacao-047")) place(frame("lavacao-047"), 230); // centro 380
|
||||
return { view, frame };
|
||||
}
|
||||
|
||||
const isActive = (element: Element) =>
|
||||
element.getAttribute("data-active") === "true";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
observers = [];
|
||||
global.IntersectionObserver =
|
||||
FakeIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
global.IntersectionObserver = OriginalObserver;
|
||||
Reflect.deleteProperty(document, "hidden");
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider: IntersectionObserver e visibilidade", () => {
|
||||
it("path: browser without IntersectionObserver -> cards register fine but none ever plays", () => {
|
||||
// @ts-expect-error simula um navegador sem IntersectionObserver
|
||||
global.IntersectionObserver = undefined;
|
||||
|
||||
const { view, frame } = setup();
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||
expect(() => view.unmount()).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0.32, false],
|
||||
[0.33, true],
|
||||
[1, true],
|
||||
])("path: %f of the card on screen -> visible: %s", (ratio, expected) => {
|
||||
const { frame } = setup(["sev-modaintima"]);
|
||||
|
||||
report(frame("sev-modaintima"), ratio);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(expected);
|
||||
});
|
||||
|
||||
it("path: a visibility report about an element that is not a card -> ignored", () => {
|
||||
const { frame } = setup(["sev-modaintima"]);
|
||||
const stranger = document.createElement("div");
|
||||
report(frame("sev-modaintima"), 0.8);
|
||||
flush();
|
||||
|
||||
report(stranger, 1);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the card leaves the screen -> it stops being the active one", () => {
|
||||
const { frame } = setup(["sev-modaintima"]);
|
||||
report(frame("sev-modaintima"), 0.8);
|
||||
flush();
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
|
||||
report(frame("sev-modaintima"), 0);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||
});
|
||||
|
||||
it("path: hidden tab -> nobody plays; back to visible -> the closest card plays again", () => {
|
||||
const { frame } = setup();
|
||||
report(frame("sev-modaintima"), 0.8);
|
||||
report(frame("lavacao-047"), 0.8);
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider: mouse e foco", () => {
|
||||
function hoverSetup() {
|
||||
const result = setup();
|
||||
report(result.frame("sev-modaintima"), 0.8);
|
||||
report(result.frame("lavacao-047"), 0.8);
|
||||
flush();
|
||||
return result;
|
||||
}
|
||||
|
||||
it("path: a pen counts as a pointer that hovers", () => {
|
||||
const { frame } = hoverSetup();
|
||||
|
||||
fireEvent.pointerEnter(
|
||||
frame("sev-modaintima").closest(".ds-card") as HTMLElement,
|
||||
{ pointerType: "pen" },
|
||||
);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
});
|
||||
|
||||
it("path: another card's pointer leaves -> the hovered card keeps the video", () => {
|
||||
const { frame } = hoverSetup();
|
||||
const sev = frame("sev-modaintima").closest(".ds-card") as HTMLElement;
|
||||
const lavacao = frame("lavacao-047").closest(".ds-card") as HTMLElement;
|
||||
fireEvent.pointerEnter(sev, { pointerType: "mouse" });
|
||||
flush();
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
|
||||
fireEvent.pointerLeave(lavacao, { pointerType: "mouse" });
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the hovered card is not on screen -> the closest visible card plays instead", () => {
|
||||
const { frame } = hoverSetup();
|
||||
report(frame("sev-modaintima"), 0);
|
||||
fireEvent.pointerEnter(
|
||||
frame("sev-modaintima").closest(".ds-card") as HTMLElement,
|
||||
{ pointerType: "mouse" },
|
||||
);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
});
|
||||
|
||||
it("path: focus moves to another element inside the same card -> hover is kept", () => {
|
||||
const { frame } = hoverSetup();
|
||||
const card = frame("sev-modaintima").closest(".ds-card") as HTMLElement;
|
||||
const inside = screen.getByRole("button", {
|
||||
name: /ampliar sev-modaintima/,
|
||||
});
|
||||
fireEvent.focusIn(inside);
|
||||
flush();
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
|
||||
fireEvent.focusOut(card, { relatedTarget: inside });
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"to another card",
|
||||
() => screen.getByRole("button", { name: /ampliar lavacao-047/ }),
|
||||
],
|
||||
["to nothing", () => null],
|
||||
])(
|
||||
"path: focus leaves the card %s -> hover is released",
|
||||
(_label, target) => {
|
||||
const { frame } = hoverSetup();
|
||||
const card = frame("sev-modaintima").closest(".ds-card") as HTMLElement;
|
||||
fireEvent.focusIn(
|
||||
screen.getByRole("button", { name: /ampliar sev-modaintima/ }),
|
||||
);
|
||||
flush();
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
|
||||
fireEvent.focusOut(card, { relatedTarget: target() });
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: the hovered card is removed from the page -> its hover is dropped and another card plays", () => {
|
||||
function Swap() {
|
||||
const [showFirst, setShowFirst] = useState(true);
|
||||
return (
|
||||
<ShowcaseProvider projects={projects}>
|
||||
{showFirst && <Card id="sev-modaintima" />}
|
||||
<Card id="lavacao-047" />
|
||||
<button type="button" onClick={() => setShowFirst(false)}>
|
||||
remover o primeiro
|
||||
</button>
|
||||
</ShowcaseProvider>
|
||||
);
|
||||
}
|
||||
render(<Swap />);
|
||||
const sev = screen.getByTestId("sev-modaintima");
|
||||
const lavacao = screen.getByTestId("lavacao-047");
|
||||
place(sev, 100);
|
||||
place(lavacao, 230);
|
||||
report(sev, 0.8);
|
||||
report(lavacao, 0.8);
|
||||
fireEvent.pointerEnter(sev.closest(".ds-card") as HTMLElement, {
|
||||
pointerType: "mouse",
|
||||
});
|
||||
flush();
|
||||
expect(isActive(sev)).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "remover o primeiro" }));
|
||||
flush();
|
||||
|
||||
expect(screen.queryByTestId("sev-modaintima")).toBeNull();
|
||||
expect(isActive(lavacao)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider: pausa e janela ampliada", () => {
|
||||
it("path: the visitor pauses and resumes -> no card plays while paused", () => {
|
||||
const { frame } = setup(["lavacao-047"]);
|
||||
report(frame("lavacao-047"), 0.8);
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /pausa lavacao-047 false/ }),
|
||||
);
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /pausa lavacao-047 true/ }),
|
||||
);
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
});
|
||||
|
||||
it("path: opening a project that is not part of the showcase -> no window", () => {
|
||||
function OpenOutsider() {
|
||||
const { open } = useShowcaseCard("reddit-explorer");
|
||||
return (
|
||||
<button type="button" onClick={open}>
|
||||
ampliar reddit-explorer
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<OpenOutsider />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /ampliar reddit-explorer/ }),
|
||||
);
|
||||
flush();
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: navigating in the window to a valid index -> changes project; to an invalid one -> closes", () => {
|
||||
setup();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /ampliar lavacao-047/ }),
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:1");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ir para o primeiro" }));
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:0");
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "ir para um que não existe" }),
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: reduced motion -> pausing and resuming never makes a card play", () => {
|
||||
reducedMotion = true;
|
||||
const { frame } = setup(["lavacao-047"]);
|
||||
report(frame("lavacao-047"), 1);
|
||||
flush();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /pausa lavacao-047 false/ }),
|
||||
);
|
||||
flush();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /pausa lavacao-047 true/ }),
|
||||
);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider: trabalho por quadro", () => {
|
||||
it("path: no card registered -> scroll and resize schedule no frame", () => {
|
||||
const raf = jest.spyOn(window, "requestAnimationFrame");
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<p>sem cards</p>
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
flush();
|
||||
raf.mockClear();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
flush();
|
||||
|
||||
expect(raf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: several scrolls inside one frame -> evaluated once", () => {
|
||||
const { frame } = setup(["lavacao-047"]);
|
||||
report(frame("lavacao-047"), 0.8);
|
||||
flush();
|
||||
const raf = jest.spyOn(window, "requestAnimationFrame");
|
||||
raf.mockClear();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(raf).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider: ganchos fora do provedor e cartão sem .ds-card", () => {
|
||||
it("path: hooks used outside the provider -> empty list, and open/toggle do nothing", () => {
|
||||
function Outsider() {
|
||||
const items = useShowcaseItems();
|
||||
const open = useShowcaseOpen();
|
||||
const { toggleAutoplay, open: openCard, active } = useShowcaseCard("x");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
open("sev-modaintima");
|
||||
openCard();
|
||||
toggleAutoplay();
|
||||
}}
|
||||
>
|
||||
itens:{items.length} ativo:{String(active)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(<Outsider />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "itens:0 ativo:false" });
|
||||
expect(() => fireEvent.click(button)).not.toThrow();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: a card whose frame has no .ds-card ancestor -> the frame itself listens for the pointer", () => {
|
||||
function Bare() {
|
||||
const { frameRef, active } = useShowcaseCard("lavacao-047");
|
||||
return <div ref={frameRef} data-testid="bare" data-active={active} />;
|
||||
}
|
||||
function Other() {
|
||||
const { frameRef, active } = useShowcaseCard("sev-modaintima");
|
||||
return <div ref={frameRef} data-testid="other" data-active={active} />;
|
||||
}
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Other />
|
||||
<Bare />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
const bare = screen.getByTestId("bare");
|
||||
const other = screen.getByTestId("other");
|
||||
place(other, 230); // mais perto do meio da tela
|
||||
place(bare, 100);
|
||||
report(other, 0.8);
|
||||
report(bare, 0.8);
|
||||
flush();
|
||||
expect(isActive(other)).toBe(true);
|
||||
|
||||
fireEvent.pointerEnter(bare, { pointerType: "mouse" });
|
||||
flush();
|
||||
|
||||
expect(isActive(bare)).toBe(true);
|
||||
expect(isActive(other)).toBe(false);
|
||||
});
|
||||
|
||||
it("path: projects missing from the list -> only showcase projects that exist are built, in showcase order", () => {
|
||||
const only = projects.filter((project) =>
|
||||
["as-servicos", "sev-modaintima"].includes(project.id),
|
||||
);
|
||||
|
||||
const items = buildShowcaseItems(only);
|
||||
|
||||
expect(items.map((item) => item.config.id)).toEqual([
|
||||
"sev-modaintima",
|
||||
"as-servicos",
|
||||
]);
|
||||
expect(buildShowcaseItems([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user