perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
1 parent
10f170abf4
commit
318a764190
77 files changed
+4293
-4951
No files matched your search
@@ -1,10 +1,11 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import HeroField from "./HeroField";
|
||||
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): a decisão de montar o WebGL
|
||||
* (requestIdleCallback x temporizador, desmontagem antes da hora, falhas ao pedir o contexto WebGL2),
|
||||
* o fundo sem a seção `.hero`, as variáveis de rolagem e a visibilidade (observador, aba oculta, fim do mergulho).
|
||||
* Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): como a espera pelo WebGL acaba
|
||||
* (primeira interação, requestIdleCallback com folga ou sem folga, temporizador), os cancelamentos, as falhas
|
||||
* (contexto WebGL2, pacote que não carrega), o fundo sem a seção `.hero`, as variáveis de rolagem e a
|
||||
* visibilidade (observador, aba oculta, fim do mergulho) e a limpeza ao desmontar.
|
||||
*/
|
||||
|
||||
type SceneProps = {
|
||||
@@ -14,16 +15,25 @@ type SceneProps = {
|
||||
onError: () => void;
|
||||
};
|
||||
const mockScenes: SceneProps[] = [];
|
||||
const mockPrepare = jest.fn();
|
||||
let mockImportFails = false;
|
||||
|
||||
jest.mock("next/dynamic", () => ({
|
||||
function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
return require("react").createElement("canvas", { "data-testid": "scene" });
|
||||
}
|
||||
|
||||
jest.mock("./HeroFieldScene", () => ({
|
||||
__esModule: true,
|
||||
default: () =>
|
||||
function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
return require("react").createElement("canvas", {
|
||||
"data-testid": "scene",
|
||||
});
|
||||
},
|
||||
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
|
||||
get default() {
|
||||
if (mockImportFails) throw new Error("chunk indisponível");
|
||||
return MockScene;
|
||||
},
|
||||
get prepareField() {
|
||||
if (mockImportFails) throw new Error("chunk indisponível");
|
||||
return () => mockPrepare();
|
||||
},
|
||||
}));
|
||||
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
@@ -36,7 +46,7 @@ const original = {
|
||||
cancelIdle: win.cancelIdleCallback,
|
||||
};
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
function mockMedia(active: string[] = [DIVE_QUERY]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
@@ -69,6 +79,8 @@ function Hero({ children }: { children?: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const section = () => document.querySelector(".hero") as HTMLElement;
|
||||
|
||||
function setHidden(value: boolean) {
|
||||
Object.defineProperty(document, "hidden", { configurable: true, value });
|
||||
}
|
||||
@@ -83,11 +95,53 @@ function reportIntersecting(value: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* requestIdleCallback de mentira: o pedido da espera pela página ociosa (timeout 4000) fica guardado para o teste
|
||||
* disparar com o prazo que quiser; os dos passos de carregamento (timeout 500) rodam logo.
|
||||
*/
|
||||
function mockIdleCallback() {
|
||||
const waiting = {
|
||||
callback: undefined as ((d: IdleDeadline) => void) | undefined,
|
||||
};
|
||||
let requests = 0;
|
||||
const request = jest.fn(
|
||||
(
|
||||
callback: (d: IdleDeadline) => void,
|
||||
options?: { timeout?: number },
|
||||
): number => {
|
||||
requests += 1;
|
||||
if (options?.timeout === 500)
|
||||
setTimeout(
|
||||
() => callback({ didTimeout: false, timeRemaining: () => 50 }),
|
||||
0,
|
||||
);
|
||||
else waiting.callback = callback;
|
||||
return requests;
|
||||
},
|
||||
);
|
||||
win.requestIdleCallback = request;
|
||||
win.cancelIdleCallback = jest.fn();
|
||||
return { waiting, request };
|
||||
}
|
||||
|
||||
const advance = (ms: number) =>
|
||||
act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
|
||||
/** Depois de a espera acabar, os passos seguintes (pacote, geometria, Canvas). */
|
||||
async function finishLoading() {
|
||||
await advance(60);
|
||||
await advance(60);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockScenes.length = 0;
|
||||
mockPrepare.mockClear();
|
||||
mockImportFails = false;
|
||||
observers = [];
|
||||
mockMedia([]);
|
||||
mockMedia();
|
||||
mockNavigator({
|
||||
hardwareConcurrency: 8,
|
||||
deviceMemory: undefined,
|
||||
@@ -127,90 +181,165 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("HeroField: quando o WebGL monta", () => {
|
||||
it("path: no requestIdleCallback -> the scene mounts after 1.2 s of timer, not before", () => {
|
||||
it("path: no requestIdleCallback -> the scene starts loading after 1.2 s of timer, not before", async () => {
|
||||
render(<Hero />);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1199);
|
||||
});
|
||||
await advance(1199);
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1);
|
||||
});
|
||||
await advance(1);
|
||||
await finishLoading();
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: requestIdleCallback available -> mounts when the browser is idle (2.5 s timeout), not on the timer", () => {
|
||||
let idle: (() => void) | undefined;
|
||||
const request = jest.fn((callback: () => void) => {
|
||||
idle = callback;
|
||||
return 42;
|
||||
it.each([
|
||||
"pointerdown",
|
||||
"pointermove",
|
||||
"keydown",
|
||||
"wheel",
|
||||
"touchstart",
|
||||
"scroll",
|
||||
])(
|
||||
"path: first %s -> the scene starts loading without waiting for the timer",
|
||||
async (type) => {
|
||||
render(<Hero />);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(type));
|
||||
});
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("path: several gestures in a row -> the scene is loaded once", async () => {
|
||||
render(<Hero />);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
window.dispatchEvent(new Event("keydown"));
|
||||
});
|
||||
win.requestIdleCallback = request;
|
||||
win.cancelIdleCallback = jest.fn();
|
||||
await finishLoading();
|
||||
await advance(3000);
|
||||
|
||||
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
||||
expect(mockScenes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: requestIdleCallback with plenty of free time -> the scene starts loading right then, not on the timer", async () => {
|
||||
const { waiting, request } = mockIdleCallback();
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
await advance(1300);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(request).toHaveBeenCalledWith(expect.any(Function), {
|
||||
timeout: 2500,
|
||||
timeout: 4000,
|
||||
});
|
||||
|
||||
act(() => idle?.());
|
||||
act(() =>
|
||||
waiting.callback?.({ didTimeout: false, timeRemaining: () => 45 }),
|
||||
);
|
||||
await finishLoading();
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: unmounted before the browser got idle -> the idle request is cancelled and no scene mounts", () => {
|
||||
it("path: an idle period with no free time -> waits for the next one; waiting too long -> goes ahead anyway", async () => {
|
||||
const { waiting, request } = mockIdleCallback();
|
||||
|
||||
render(<Hero />);
|
||||
const first = request.mock.calls.length;
|
||||
|
||||
act(() =>
|
||||
waiting.callback?.({ didTimeout: false, timeRemaining: () => 3 }),
|
||||
);
|
||||
await finishLoading();
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(request.mock.calls.length).toBeGreaterThan(first); // pediu o próximo intervalo
|
||||
|
||||
act(() => waiting.callback?.({ didTimeout: true, timeRemaining: () => 0 }));
|
||||
await finishLoading();
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: unmounted while waiting for the browser to get idle -> the request is cancelled and no scene mounts", async () => {
|
||||
const cancel = jest.fn();
|
||||
win.requestIdleCallback = jest.fn(() => 7);
|
||||
win.cancelIdleCallback = cancel;
|
||||
|
||||
const view = render(<Hero />);
|
||||
view.unmount();
|
||||
await advance(5000);
|
||||
|
||||
expect(cancel).toHaveBeenCalledWith(7);
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: unmounted before the fallback timer fires -> nothing mounts afterwards", () => {
|
||||
it("path: unmounted before the fallback timer fires -> nothing loads afterwards, and the gestures no longer matter", async () => {
|
||||
const view = render(<Hero />);
|
||||
view.unmount();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(5000);
|
||||
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: WebGL2 constructor exists but creating the context throws -> static background", () => {
|
||||
it("path: unmounted in the middle of loading (after the package, before the geometry) -> nothing mounts", async () => {
|
||||
const view = render(<Hero />);
|
||||
await advance(1200);
|
||||
|
||||
view.unmount();
|
||||
await finishLoading();
|
||||
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroField: falhas voltam ao hero simples", () => {
|
||||
it("path: WebGL2 constructor exists but creating the context throws -> static background, no dive", async () => {
|
||||
setContext(() => {
|
||||
throw new Error("sem GPU");
|
||||
});
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: WebGL2 constructor exists but the context is null -> static background", () => {
|
||||
it("path: WebGL2 constructor exists but the context is null -> static background, no dive", async () => {
|
||||
setContext(() => null);
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: the probe context is released right away (WEBGL_lose_context) before the real scene mounts", () => {
|
||||
it("path: the three.js package fails to load (network) -> static background, no dive, no scene", async () => {
|
||||
mockImportFails = true;
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: the probe context is released right away (WEBGL2 lose_context) before the real scene mounts", async () => {
|
||||
const loseContext = jest.fn();
|
||||
setContext(() => ({
|
||||
getExtension: (name: string) =>
|
||||
@@ -218,46 +347,48 @@ describe("HeroField: quando o WebGL monta", () => {
|
||||
}));
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(loseContext).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: the lose-context extension is missing -> the probe still succeeds", () => {
|
||||
it("path: the lose-context extension is missing -> the probe still succeeds", async () => {
|
||||
setContext(() => ({ getExtension: () => null }));
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: low power -> the WebGL probe and the timer are never started", () => {
|
||||
it("path: low power -> the WebGL probe, the timer and the gesture listeners are never started", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||
setContext(probe);
|
||||
const addListener = jest.spyOn(window, "addEventListener");
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(3000);
|
||||
await finishLoading();
|
||||
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
const types = addListener.mock.calls.map(([type]) => type);
|
||||
expect(types).not.toContain("pointermove");
|
||||
expect(types).not.toContain("scroll");
|
||||
expect(observers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroField: rolagem e visibilidade", () => {
|
||||
function section() {
|
||||
return document.querySelector(".hero") as HTMLElement;
|
||||
}
|
||||
|
||||
function place(top: number, height = 2000) {
|
||||
function place(top: number, height = 3000) {
|
||||
section().getBoundingClientRect = () =>
|
||||
({ top, height, bottom: top + height }) as DOMRect;
|
||||
}
|
||||
@@ -270,6 +401,11 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function loaded() {
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
@@ -277,15 +413,13 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("path: no .hero ancestor -> no scroll variables and no observer, but the scene still mounts (not visible)", () => {
|
||||
it("path: no .hero ancestor -> no scroll variables and no observer, and nothing else starts either", async () => {
|
||||
render(<HeroField />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await advance(3000);
|
||||
await finishLoading();
|
||||
|
||||
expect(observers).toHaveLength(0);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: scrolling to the same progress twice -> the CSS variables are written once", () => {
|
||||
@@ -302,13 +436,14 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
|
||||
it("path: resize recomputes the progress like a scroll", () => {
|
||||
render(<Hero />);
|
||||
place(-600);
|
||||
place(-700);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
|
||||
// trilho de 1400 px (3000 de hero - 2 telas de 800)
|
||||
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
||||
0.5,
|
||||
);
|
||||
@@ -322,25 +457,43 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
expect(Number(section().style.getPropertyValue("--hero-p") || 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: end of the dive then back up -> drawing stops, then resumes", () => {
|
||||
it("path: scroll far past the dive (Projects covering the stage) -> progress clamps at 1", () => {
|
||||
render(<Hero />);
|
||||
|
||||
scroll(-2200);
|
||||
|
||||
expect(Number(section().style.getPropertyValue("--hero-p"))).toBe(1);
|
||||
});
|
||||
|
||||
it("path: a hero shorter than the minimum rail -> the rail is 80 % of the window, never zero", () => {
|
||||
render(<Hero />);
|
||||
place(-400, 800);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
|
||||
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
||||
400 / 640,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: end of the dive then back up -> drawing stops, then resumes", async () => {
|
||||
render(<Hero />);
|
||||
await loaded();
|
||||
reportIntersecting(true);
|
||||
|
||||
scroll(-1200);
|
||||
scroll(-1400);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
|
||||
scroll(-300);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the hero leaves the screen -> not visible; returns -> visible", () => {
|
||||
it("path: the hero leaves the screen -> not visible; returns -> visible", async () => {
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await loaded();
|
||||
|
||||
reportIntersecting(true);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
@@ -352,11 +505,9 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", () => {
|
||||
it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", async () => {
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await loaded();
|
||||
reportIntersecting(true);
|
||||
|
||||
setHidden(true);
|
||||
@@ -372,11 +523,9 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the tab shows but the hero is off screen -> still not visible", () => {
|
||||
it("path: the tab shows but the hero is off screen -> still not visible", async () => {
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
await loaded();
|
||||
reportIntersecting(false);
|
||||
|
||||
act(() => {
|
||||
@@ -386,11 +535,14 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("path: unmount -> listeners removed, observer disconnected, pending frame cancelled", () => {
|
||||
it("path: unmount -> listeners removed, observer disconnected, pending frame cancelled, hero back to plain", () => {
|
||||
const removeListener = jest.spyOn(window, "removeEventListener");
|
||||
const removeDocListener = jest.spyOn(document, "removeEventListener");
|
||||
const cancelFrame = jest.spyOn(window, "cancelAnimationFrame");
|
||||
const view = render(<Hero />);
|
||||
const hero = section();
|
||||
scroll(-700);
|
||||
expect(hero).toHaveAttribute("data-dive");
|
||||
|
||||
view.unmount();
|
||||
|
||||
@@ -401,5 +553,21 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
);
|
||||
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(cancelFrame).toHaveBeenCalled();
|
||||
expect(hero).not.toHaveAttribute("data-dive");
|
||||
expect(hero.style.getPropertyValue("--hero-p")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroField: navegador sem matchMedia", () => {
|
||||
it("path: no matchMedia at all -> the dive is not started, but the ambient scene still mounts", async () => {
|
||||
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(section().style.getPropertyValue("--hero-p")).toBe("");
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user