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
@@ -2,88 +2,134 @@ import { act, render } from "@testing-library/react";
|
||||
import FilmGrain from "./FilmGrain";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): cada condição do retângulo
|
||||
* de uma tela de projeto (altura, fim acima do topo da janela, início abaixo da janela), várias telas, quadros
|
||||
* agrupados e a limpeza ao desmontar.
|
||||
* Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): a medição é do
|
||||
* IntersectionObserver (nada ouve a rolagem nem mede a página a cada quadro), uma tela escondida por CSS não
|
||||
* conta, várias telas, telas que a página cria ou remove depois e a limpeza ao desmontar.
|
||||
*/
|
||||
|
||||
const INNER_HEIGHT = 800;
|
||||
type Observer = {
|
||||
callback: IntersectionObserverCallback;
|
||||
observed: Set<Element>;
|
||||
/** quantas vezes observe() foi chamado (um conjunto esconderia repetições) */
|
||||
calls: number;
|
||||
disconnect: jest.Mock;
|
||||
};
|
||||
let observers: Observer[] = [];
|
||||
const original = global.IntersectionObserver;
|
||||
|
||||
function addScreen(
|
||||
className: string,
|
||||
rect: { top: number; bottom: number; height: number },
|
||||
) {
|
||||
beforeEach(() => {
|
||||
observers = [];
|
||||
global.IntersectionObserver = class {
|
||||
private readonly record: Observer;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.record = {
|
||||
callback,
|
||||
observed: new Set(),
|
||||
calls: 0,
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
observers.push(this.record);
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.record.observed.add(target);
|
||||
this.record.calls += 1;
|
||||
}
|
||||
unobserve(target: Element) {
|
||||
this.record.observed.delete(target);
|
||||
}
|
||||
disconnect() {
|
||||
this.record.disconnect();
|
||||
}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.IntersectionObserver = original;
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function addScreen(className: string) {
|
||||
const element = document.createElement("div");
|
||||
element.className = className;
|
||||
element.getBoundingClientRect = () => rect as DOMRect;
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: INNER_HEIGHT,
|
||||
function intersect(target: Element, isIntersecting: boolean, height = 600) {
|
||||
act(() => {
|
||||
for (const observer of observers)
|
||||
if (observer.observed.has(target))
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
target,
|
||||
isIntersecting,
|
||||
boundingClientRect: { height },
|
||||
} as unknown as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
}
|
||||
|
||||
function renderGrain() {
|
||||
const view = render(<FilmGrain />);
|
||||
return { ...view, grain: view.container.firstElementChild as HTMLElement };
|
||||
}
|
||||
|
||||
/** Deixa o MutationObserver (microtarefa) avisar. */
|
||||
const flush = () =>
|
||||
act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
describe("FilmGrain: is a project screen in view?", () => {
|
||||
it("path: a visible screen -> the grain steps aside", () => {
|
||||
addScreen("ds-phone", { top: 100, bottom: 700, height: 600 });
|
||||
|
||||
const screen = addScreen("ds-phone");
|
||||
const { grain } = renderGrain();
|
||||
|
||||
intersect(screen, true);
|
||||
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["has no height (hidden by CSS)", { top: 100, bottom: 100, height: 0 }],
|
||||
["ended exactly at the top edge", { top: -600, bottom: 0, height: 600 }],
|
||||
["ended above the window", { top: -900, bottom: -300, height: 600 }],
|
||||
[
|
||||
"starts exactly at the bottom edge",
|
||||
{ top: INNER_HEIGHT, bottom: 1400, height: 600 },
|
||||
],
|
||||
["starts below the window", { top: 2000, bottom: 2600, height: 600 }],
|
||||
])("path: a screen that %s -> does not count", (_label, rect) => {
|
||||
addScreen("ds-bframe", rect);
|
||||
|
||||
it("path: a screen with no height (hidden by CSS) -> does not count even if the browser says it intersects", () => {
|
||||
const screen = addScreen("ds-bframe");
|
||||
const { grain } = renderGrain();
|
||||
|
||||
intersect(screen, true, 0);
|
||||
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: just inside the window by one pixel at either edge -> counts", () => {
|
||||
addScreen("ds-frame", { top: -599, bottom: 1, height: 600 });
|
||||
const top = renderGrain();
|
||||
expect(top.grain).toHaveAttribute("data-over-projects");
|
||||
top.unmount();
|
||||
document.body.innerHTML = "";
|
||||
|
||||
addScreen("ds-frame", { top: INNER_HEIGHT - 1, bottom: 1400, height: 600 });
|
||||
const bottom = renderGrain();
|
||||
expect(bottom.grain).toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: one screen off the window and another in view -> counts (any is enough)", () => {
|
||||
addScreen("ds-reel", { top: 5000, bottom: 5600, height: 600 });
|
||||
addScreen("ds-phone", { top: 100, bottom: 700, height: 600 });
|
||||
|
||||
it("path: a screen that is off the window -> does not count", () => {
|
||||
const screen = addScreen("ds-frame");
|
||||
const { grain } = renderGrain();
|
||||
|
||||
intersect(screen, false);
|
||||
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: one screen off the window and another in view -> counts (any is enough), and leaves only when the last one leaves", () => {
|
||||
const far = addScreen("ds-reel");
|
||||
const near = addScreen("ds-phone");
|
||||
const { grain } = renderGrain();
|
||||
|
||||
intersect(far, false);
|
||||
intersect(near, true);
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
|
||||
intersect(far, true);
|
||||
intersect(near, false);
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
|
||||
intersect(far, false);
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: no project screen on the page -> the grain stays", () => {
|
||||
@@ -93,47 +139,83 @@ describe("FilmGrain: is a project screen in view?", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilmGrain: frames and cleanup", () => {
|
||||
it("path: many scroll events inside one frame -> the page is measured once", () => {
|
||||
const screenEl = addScreen("ds-reel", {
|
||||
top: 5000,
|
||||
bottom: 5600,
|
||||
height: 600,
|
||||
});
|
||||
const measure = jest.spyOn(screenEl, "getBoundingClientRect");
|
||||
renderGrain();
|
||||
measure.mockClear();
|
||||
describe("FilmGrain: screens that appear and disappear", () => {
|
||||
it("path: a screen the page creates after the grain mounted (the pinned reel swapping the list) is observed too", async () => {
|
||||
const { grain } = renderGrain();
|
||||
expect(observers[0].observed.size).toBe(0);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
const reel = addScreen("ds-reel");
|
||||
await flush();
|
||||
expect(observers[0].observed.has(reel)).toBe(true);
|
||||
|
||||
expect(measure).toHaveBeenCalledTimes(1);
|
||||
intersect(reel, true);
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: unmount -> listeners removed and no measurement afterwards", () => {
|
||||
const screenEl = addScreen("ds-reel", {
|
||||
top: 5000,
|
||||
bottom: 5600,
|
||||
height: 600,
|
||||
});
|
||||
const measure = jest.spyOn(screenEl, "getBoundingClientRect");
|
||||
const { unmount } = renderGrain();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
it("path: a screen that is removed while in view stops counting", async () => {
|
||||
const reel = addScreen("ds-reel");
|
||||
const { grain } = renderGrain();
|
||||
intersect(reel, true);
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
|
||||
reel.remove();
|
||||
await flush();
|
||||
|
||||
expect(observers[0].observed.has(reel)).toBe(false);
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: the same screen is never observed twice", async () => {
|
||||
addScreen("ds-reel");
|
||||
renderGrain();
|
||||
expect(observers[0].calls).toBe(1);
|
||||
|
||||
addScreen("ds-phone");
|
||||
await flush();
|
||||
|
||||
// só a tela nova foi pedida ao observador; a primeira não se repete
|
||||
expect(observers[0].calls).toBe(2);
|
||||
expect(observers[0].observed.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilmGrain: no work per frame, cleanup", () => {
|
||||
it("path: nothing listens to the scroll or the window size, and the page is never measured", () => {
|
||||
const screen = addScreen("ds-reel");
|
||||
const measure = jest.spyOn(screen, "getBoundingClientRect");
|
||||
const listen = jest.spyOn(window, "addEventListener");
|
||||
renderGrain();
|
||||
|
||||
unmount();
|
||||
measure.mockClear();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(measure).not.toHaveBeenCalled();
|
||||
const types = listen.mock.calls.map(([type]) => type);
|
||||
expect(types).not.toContain("scroll");
|
||||
expect(types).not.toContain("resize");
|
||||
});
|
||||
|
||||
it("path: unmount -> the observers are disconnected and later screens are ignored", async () => {
|
||||
const { unmount } = renderGrain();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
addScreen("ds-reel");
|
||||
await flush();
|
||||
expect(observers[0].observed.size).toBe(0);
|
||||
});
|
||||
|
||||
it("path: a browser without IntersectionObserver -> the grain renders and simply stays", () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: simula um navegador sem a API
|
||||
(global as any).IntersectionObserver = undefined;
|
||||
addScreen("ds-reel");
|
||||
|
||||
const { grain } = renderGrain();
|
||||
|
||||
expect(grain).toHaveClass("film-grain");
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user