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.
298 lines
8.8 KiB
TypeScript
298 lines
8.8 KiB
TypeScript
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);
|
|
});
|
|
});
|