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,297 @@
|
||||
import { act, render, renderHook } from "@testing-library/react";
|
||||
import { type RefObject, useRef } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MOBILE_QUERY } from "@/lib/device";
|
||||
import {
|
||||
NEAR_MARGIN,
|
||||
PINNED_QUERY,
|
||||
useNearViewport,
|
||||
usePinnedViewport,
|
||||
useSaveData,
|
||||
useViewportDevice,
|
||||
} from "./useShowcaseMedia";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base dos hooks de mídia da vitrine: aparelho (celular x desktop x sem matchMedia),
|
||||
* vitrine fixa cabe ou não, "economizar dados" e a proximidade com a janela (com e sem IntersectionObserver).
|
||||
*/
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const OriginalObserver = global.IntersectionObserver;
|
||||
|
||||
type MediaListener = () => void;
|
||||
type FakeQuery = {
|
||||
query: string;
|
||||
matches: boolean;
|
||||
listeners: Set<MediaListener>;
|
||||
};
|
||||
let queries: FakeQuery[] = [];
|
||||
|
||||
/** matchMedia falso: `matching` diz quais consultas valem; cada chamada devolve um objeto que guarda os ouvintes. */
|
||||
function mockMedia(matching: string[]) {
|
||||
queries = [];
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => {
|
||||
const fake: FakeQuery = {
|
||||
query,
|
||||
matches: matching.includes(query),
|
||||
listeners: new Set(),
|
||||
};
|
||||
queries.push(fake);
|
||||
return {
|
||||
get matches() {
|
||||
return fake.matches;
|
||||
},
|
||||
media: query,
|
||||
addEventListener: (_type: string, listener: MediaListener) =>
|
||||
fake.listeners.add(listener),
|
||||
removeEventListener: (_type: string, listener: MediaListener) =>
|
||||
fake.listeners.delete(listener),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mockNavigator(values: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(values))
|
||||
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
global.IntersectionObserver = OriginalObserver;
|
||||
mockNavigator({ connection: undefined });
|
||||
});
|
||||
|
||||
describe("useViewportDevice", () => {
|
||||
it("path: server render (no effect yet) -> null, so no media file is chosen", () => {
|
||||
function Probe() {
|
||||
return <span>{String(useViewportDevice())}</span>;
|
||||
}
|
||||
|
||||
expect(renderToString(<Probe />)).toContain("null");
|
||||
});
|
||||
|
||||
it("path: browser without matchMedia -> desktop", () => {
|
||||
// @ts-expect-error simula um navegador sem matchMedia
|
||||
window.matchMedia = undefined;
|
||||
|
||||
const { result } = renderHook(() => useViewportDevice());
|
||||
|
||||
expect(result.current).toBe("desktop");
|
||||
});
|
||||
|
||||
it("path: phone width matches -> mobile", () => {
|
||||
mockMedia([MOBILE_QUERY]);
|
||||
|
||||
const { result } = renderHook(() => useViewportDevice());
|
||||
|
||||
expect(result.current).toBe("mobile");
|
||||
expect(queries.map((q) => q.query)).toEqual([MOBILE_QUERY]);
|
||||
});
|
||||
|
||||
it("path: phone width does not match -> desktop", () => {
|
||||
mockMedia([]);
|
||||
|
||||
const { result } = renderHook(() => useViewportDevice());
|
||||
|
||||
expect(result.current).toBe("desktop");
|
||||
});
|
||||
|
||||
it("path: the window is resized across the breakpoint -> follows both ways", () => {
|
||||
mockMedia([]);
|
||||
const { result } = renderHook(() => useViewportDevice());
|
||||
expect(result.current).toBe("desktop");
|
||||
|
||||
act(() => {
|
||||
queries[0].matches = true;
|
||||
for (const listener of queries[0].listeners) listener();
|
||||
});
|
||||
expect(result.current).toBe("mobile");
|
||||
|
||||
act(() => {
|
||||
queries[0].matches = false;
|
||||
for (const listener of queries[0].listeners) listener();
|
||||
});
|
||||
expect(result.current).toBe("desktop");
|
||||
});
|
||||
|
||||
it("path: unmount -> the media listener is removed", () => {
|
||||
mockMedia([]);
|
||||
const { unmount } = renderHook(() => useViewportDevice());
|
||||
expect(queries[0].listeners.size).toBe(1);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(queries[0].listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePinnedViewport", () => {
|
||||
it("path: server render -> null until the browser mounts", () => {
|
||||
function Probe() {
|
||||
return <span>{String(usePinnedViewport())}</span>;
|
||||
}
|
||||
|
||||
expect(renderToString(<Probe />)).toContain("null");
|
||||
});
|
||||
|
||||
it("path: browser without matchMedia -> the pinned reel is allowed", () => {
|
||||
// @ts-expect-error simula um navegador sem matchMedia
|
||||
window.matchMedia = undefined;
|
||||
|
||||
const { result } = renderHook(() => usePinnedViewport());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the screen is wide and tall enough -> true, using the shared query", () => {
|
||||
mockMedia([PINNED_QUERY]);
|
||||
|
||||
const { result } = renderHook(() => usePinnedViewport());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(queries.map((q) => q.query)).toEqual([PINNED_QUERY]);
|
||||
expect(PINNED_QUERY).toBe("(min-width: 768px) and (min-height: 561px)");
|
||||
});
|
||||
|
||||
it("path: the screen is too small (phone or short window) -> false", () => {
|
||||
mockMedia([]);
|
||||
|
||||
const { result } = renderHook(() => usePinnedViewport());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("path: the window changes size -> follows, and unmount removes the listener", () => {
|
||||
mockMedia([]);
|
||||
const { result, unmount } = renderHook(() => usePinnedViewport());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
queries[0].matches = true;
|
||||
for (const listener of queries[0].listeners) listener();
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
unmount();
|
||||
expect(queries[0].listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSaveData", () => {
|
||||
it.each([
|
||||
["saveData on", { saveData: true }, true],
|
||||
["saveData off", { saveData: false }, false],
|
||||
["no connection info", undefined, false],
|
||||
])("path: %s -> %s", (_label, connection, expected) => {
|
||||
mockNavigator({ connection });
|
||||
|
||||
const { result } = renderHook(() => useSaveData());
|
||||
|
||||
expect(result.current).toBe(expected);
|
||||
});
|
||||
|
||||
it("path: server render -> false (the lightest assumption is not 'save data')", () => {
|
||||
mockNavigator({ connection: { saveData: true } });
|
||||
function Probe() {
|
||||
return <span>{String(useSaveData())}</span>;
|
||||
}
|
||||
|
||||
expect(renderToString(<Probe />)).toContain("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useNearViewport", () => {
|
||||
type Entry = { isIntersecting: boolean };
|
||||
let callbacks: Array<(entries: Entry[]) => void> = [];
|
||||
let observers: Array<{
|
||||
observe: jest.Mock;
|
||||
disconnect: jest.Mock;
|
||||
options?: IntersectionObserverInit;
|
||||
}> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
callbacks = [];
|
||||
observers = [];
|
||||
global.IntersectionObserver = class {
|
||||
observe = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
constructor(
|
||||
callback: (entries: Entry[]) => void,
|
||||
options?: IntersectionObserverInit,
|
||||
) {
|
||||
callbacks.push(callback);
|
||||
observers.push({
|
||||
observe: this.observe,
|
||||
disconnect: this.disconnect,
|
||||
options,
|
||||
});
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
function Probe({ attach = true }: { attach?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const near = useNearViewport(ref as RefObject<Element | null>);
|
||||
return (
|
||||
<div>
|
||||
{attach && <div ref={ref} data-testid="target" />}
|
||||
<output>{String(near)}</output>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
it("path: element far from the window -> false, observing it with a one-screen margin", () => {
|
||||
const view = render(<Probe />);
|
||||
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("false");
|
||||
expect(observers).toHaveLength(1);
|
||||
expect(observers[0].options).toEqual({ rootMargin: NEAR_MARGIN });
|
||||
expect(observers[0].observe).toHaveBeenCalledWith(
|
||||
view.getByTestId("target"),
|
||||
);
|
||||
});
|
||||
|
||||
it("path: the observer reports nothing intersecting -> still false", () => {
|
||||
const view = render(<Probe />);
|
||||
|
||||
act(() => callbacks[0]([{ isIntersecting: false }]));
|
||||
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
it("path: the element gets within a screen -> true, and it stays true when it leaves again", () => {
|
||||
const view = render(<Probe />);
|
||||
|
||||
act(() =>
|
||||
callbacks[0]([{ isIntersecting: false }, { isIntersecting: true }]),
|
||||
);
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("true");
|
||||
|
||||
act(() => callbacks[0]([{ isIntersecting: false }]));
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
it("path: no ref target -> no observer is created and the answer stays false", () => {
|
||||
const view = render(<Probe attach={false} />);
|
||||
|
||||
expect(observers).toHaveLength(0);
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
it("path: browser without IntersectionObserver -> assumes near so the poster still loads", () => {
|
||||
// @ts-expect-error simula um navegador sem IntersectionObserver
|
||||
global.IntersectionObserver = undefined;
|
||||
|
||||
const view = render(<Probe />);
|
||||
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
it("path: unmount -> the observer is disconnected", () => {
|
||||
const view = render(<Probe />);
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user