fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve
Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
This commit is contained in:
1 parent
9e2d65a926
commit
204ebd5fa5
49 files changed
+2197
-606
No files matched your search
@@ -3,7 +3,9 @@ import HeroFieldScene from "./HeroFieldScene";
|
||||
|
||||
/*
|
||||
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
|
||||
* liga a batida só com o hero à vista e solta tudo ao sair. O WebGL do jsdom não existe: o renderizador é um dublê.
|
||||
* liga a batida só com o hero à vista e solta tudo ao sair. A cena nasce em tarefas separadas (renderizador,
|
||||
* compilação do shader, primeiro quadro): os testes deixam essas tarefas correrem com `mount`. O WebGL do jsdom não
|
||||
* existe: o renderizador é um dublê.
|
||||
*/
|
||||
|
||||
type RendererOptions = {
|
||||
@@ -13,12 +15,15 @@ type RendererOptions = {
|
||||
};
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockFailToCreate = false;
|
||||
/** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */
|
||||
let mockCompile: () => Promise<unknown> = () => Promise.resolve();
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
pixelRatio = 1;
|
||||
options: RendererOptions;
|
||||
render = jest.fn();
|
||||
compileAsync = jest.fn(() => mockCompile());
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
@@ -59,6 +64,7 @@ beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockRenderers.length = 0;
|
||||
mockFailToCreate = false;
|
||||
mockCompile = () => Promise.resolve();
|
||||
setDevicePixelRatio(1);
|
||||
});
|
||||
|
||||
@@ -66,9 +72,27 @@ afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/** Deixa as tarefas da montagem correrem (renderizador, compilação do shader, primeiro quadro pedido). */
|
||||
const buildScene = (ms = 10) =>
|
||||
act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
|
||||
/** Só a tarefa que já estava na fila (as que ela mesma agenda esperam a próxima chamada): um passo da montagem. */
|
||||
const nextTask = () =>
|
||||
act(async () => {
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
});
|
||||
|
||||
async function mount(ui: React.ReactElement) {
|
||||
const result = render(ui);
|
||||
await buildScene();
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("HeroFieldScene (ambient 3D)", () => {
|
||||
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", () => {
|
||||
const { container } = render(<HeroFieldScene {...props()} />);
|
||||
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", async () => {
|
||||
const { container } = await mount(<HeroFieldScene {...props()} />);
|
||||
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].options).toMatchObject({
|
||||
@@ -88,15 +112,18 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
[1.25, 1.25],
|
||||
[2, 1.5],
|
||||
[3, 1.5],
|
||||
])("caps the pixel ratio at 1.5 (device %s -> %s)", (device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
});
|
||||
])(
|
||||
"caps the pixel ratio at 1.5 (device %s -> %s)",
|
||||
async (device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", () => {
|
||||
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
expect(frames).toHaveBeenCalledTimes(1);
|
||||
|
||||
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
|
||||
@@ -115,9 +142,9 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("tells the page it is ready after the second frame", () => {
|
||||
it("tells the page it is ready after the second frame", async () => {
|
||||
const scene = props();
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
await mount(<HeroFieldScene {...scene} />);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
@@ -127,10 +154,12 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", () => {
|
||||
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
const { rerender } = render(<HeroFieldScene {...scene} visible={false} />);
|
||||
const { rerender } = await mount(
|
||||
<HeroFieldScene {...scene} visible={false} />,
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(6000);
|
||||
});
|
||||
@@ -158,9 +187,9 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("releases the context, the canvas and the timers when it unmounts", () => {
|
||||
it("releases the context, the canvas and the timers when it unmounts", async () => {
|
||||
const scene = props();
|
||||
const { container, unmount } = render(
|
||||
const { container, unmount } = await mount(
|
||||
<HeroFieldScene {...scene} visible />,
|
||||
);
|
||||
expect(container.querySelector("canvas")).not.toBeNull();
|
||||
@@ -178,9 +207,9 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||
});
|
||||
|
||||
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", () => {
|
||||
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", async () => {
|
||||
const first = props();
|
||||
const { rerender } = render(<HeroFieldScene {...first} />);
|
||||
const { rerender } = await mount(<HeroFieldScene {...first} />);
|
||||
const second = props();
|
||||
rerender(
|
||||
<HeroFieldScene
|
||||
@@ -200,11 +229,11 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
expect(second.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tells the page and renders nothing but the empty host when WebGL cannot be created", () => {
|
||||
it("tells the page and renders nothing but the empty host when WebGL cannot be created", async () => {
|
||||
mockFailToCreate = true;
|
||||
const scene = props();
|
||||
|
||||
const { container } = render(<HeroFieldScene {...scene} />);
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
@@ -217,18 +246,18 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
[3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente
|
||||
])(
|
||||
"draws at a pixel ratio of %p -> the renderer gets %p",
|
||||
(device, expected) => {
|
||||
async (device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("tells the page when the WebGL context is lost after mounting, through the newest callback", () => {
|
||||
it("tells the page when the WebGL context is lost after mounting, through the newest callback", async () => {
|
||||
const scene = props();
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
act(() => {
|
||||
mockRenderers[0].domElement.dispatchEvent(
|
||||
@@ -238,4 +267,142 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("built in separate tasks (no single long task)", () => {
|
||||
it("takes the steps one task at a time: the renderer, the point cloud and the shader compile, then the first frame", async () => {
|
||||
let finishCompile = () => {};
|
||||
mockCompile = () =>
|
||||
new Promise((resolve) => {
|
||||
finishCompile = () => resolve(undefined);
|
||||
});
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
// nada acontece dentro do commit do React: nem o contexto WebGL
|
||||
expect(mockRenderers).toHaveLength(0);
|
||||
|
||||
await nextTask();
|
||||
// tarefa 1: só o renderizador (o contexto WebGL); a nuvem de pontos e o shader esperam a vez
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
await nextTask();
|
||||
// tarefa 2: a nuvem de pontos e o pedido de compilação; o shader compila fora da thread principal e nada foi desenhado
|
||||
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
|
||||
|
||||
finishCompile();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
// a compilação acabou, mas o tamanho e o primeiro quadro esperam a própria tarefa
|
||||
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
await nextTask();
|
||||
// tarefa 3: tamanho e o pedido do primeiro quadro, que sai noutra tarefa (requestAnimationFrame)
|
||||
expect(mockRenderers[0].setSize).toHaveBeenCalled();
|
||||
expect(frames).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].render).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(mockRenderers[0].render).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("compiles the very scene it is going to draw", async () => {
|
||||
await mount(<HeroFieldScene {...props()} />);
|
||||
|
||||
const [scene, camera] = mockRenderers[0].compileAsync.mock
|
||||
.calls[0] as unknown as [{ isScene: boolean }, { isCamera: boolean }];
|
||||
expect(scene.isScene).toBe(true);
|
||||
expect(camera.isCamera).toBe(true);
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(mockRenderers[0].render.mock.calls[0][0]).toBe(scene);
|
||||
});
|
||||
|
||||
it("creates nothing when it is unmounted before the first task (StrictMode, a quick scroll away)", async () => {
|
||||
const scene = props();
|
||||
const { unmount } = render(<HeroFieldScene {...scene} />);
|
||||
|
||||
unmount();
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers).toHaveLength(0);
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("frees the context if it is unmounted between the renderer and the point cloud", async () => {
|
||||
const { container, unmount } = render(<HeroFieldScene {...props()} />);
|
||||
await nextTask();
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
|
||||
unmount();
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it("releases the context if it is unmounted while the shader compiles", async () => {
|
||||
let finishCompile = () => {};
|
||||
mockCompile = () =>
|
||||
new Promise((resolve) => {
|
||||
finishCompile = () => resolve(undefined);
|
||||
});
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const { container, unmount } = await mount(
|
||||
<HeroFieldScene {...props()} />,
|
||||
);
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
|
||||
unmount();
|
||||
finishCompile();
|
||||
await buildScene();
|
||||
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tells the page, and frees the context, when the shader does not compile", async () => {
|
||||
mockCompile = () => Promise.reject(new Error("shader inválido"));
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies the visibility the page asked for while it was still being built", async () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
const { rerender } = render(
|
||||
<HeroFieldScene {...scene} visible={false} />,
|
||||
);
|
||||
rerender(<HeroFieldScene {...scene} visible />);
|
||||
|
||||
await buildScene();
|
||||
await act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(980);
|
||||
});
|
||||
|
||||
// a batida de 30 quadros por segundo ligou assim que a cena nasceu
|
||||
expect(frames.mock.calls.length).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user