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.
207 lines
5.8 KiB
TypeScript
207 lines
5.8 KiB
TypeScript
import { act, render } from "@testing-library/react";
|
|
import HeroScene from "./HeroScene";
|
|
|
|
/**
|
|
* Testes de caminhos-base de HeroScene (fundo 3D das páginas internas): quando o desenho está ligado
|
|
* (visível E à vista), a câmera que segue o mouse (CameraRig) e o relógio do chão em grade (GridFloor).
|
|
* O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega os callbacks do useFrame.
|
|
*/
|
|
|
|
const mockFrames: Array<(state: unknown, delta: number) => void> = [];
|
|
const mockCamera = {
|
|
position: { x: 0, y: 0.5, z: 8 },
|
|
lookAt: jest.fn(),
|
|
};
|
|
const mockPulse = jest.fn();
|
|
|
|
jest.mock("@react-three/fiber", () => ({
|
|
Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
useFrame: (callback: (state: unknown, delta: number) => void) => {
|
|
mockFrames.push(callback);
|
|
},
|
|
useThree: () => ({ camera: mockCamera }),
|
|
}));
|
|
jest.mock("./FramePulse", () => ({
|
|
__esModule: true,
|
|
default: (props: { active: boolean }) => {
|
|
mockPulse(props.active);
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
type Observer = {
|
|
callback: IntersectionObserverCallback;
|
|
disconnect: jest.Mock;
|
|
};
|
|
let observers: Observer[] = [];
|
|
const OriginalObserver = global.IntersectionObserver;
|
|
|
|
beforeEach(() => {
|
|
mockFrames.length = 0;
|
|
mockPulse.mockClear();
|
|
mockCamera.position = { x: 0, y: 0.5, z: 8 };
|
|
mockCamera.lookAt.mockClear();
|
|
observers = [];
|
|
global.IntersectionObserver = class {
|
|
private readonly record: Observer;
|
|
constructor(callback: IntersectionObserverCallback) {
|
|
this.record = { callback, disconnect: jest.fn() };
|
|
observers.push(this.record);
|
|
}
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {
|
|
this.record.disconnect();
|
|
}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
} as unknown as typeof IntersectionObserver;
|
|
// as tags do R3F (fog, mesh, ...) são elementos desconhecidos no DOM do jsdom
|
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.IntersectionObserver = OriginalObserver;
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
const lastPulse = () =>
|
|
mockPulse.mock.calls[mockPulse.mock.calls.length - 1][0];
|
|
|
|
function report(isIntersecting: boolean) {
|
|
act(() => {
|
|
observers[0].callback(
|
|
[{ isIntersecting } as IntersectionObserverEntry],
|
|
{} as IntersectionObserver,
|
|
);
|
|
});
|
|
}
|
|
|
|
describe("HeroScene: quando desenha", () => {
|
|
it("path: visible by prop and in view (default) -> the pulse is on", () => {
|
|
render(<HeroScene />);
|
|
|
|
expect(lastPulse()).toBe(true);
|
|
});
|
|
|
|
it("path: isVisible=false -> the pulse is off even though the scene is in view", () => {
|
|
render(<HeroScene isVisible={false} />);
|
|
|
|
expect(lastPulse()).toBe(false);
|
|
});
|
|
|
|
it("path: scrolled out of view -> off; back in view -> on", () => {
|
|
render(<HeroScene />);
|
|
|
|
report(false);
|
|
expect(lastPulse()).toBe(false);
|
|
|
|
report(true);
|
|
expect(lastPulse()).toBe(true);
|
|
});
|
|
|
|
it("path: out of view and isVisible=false -> off", () => {
|
|
render(<HeroScene isVisible={false} />);
|
|
|
|
report(false);
|
|
|
|
expect(lastPulse()).toBe(false);
|
|
});
|
|
|
|
it("path: unmount -> the observer is disconnected", () => {
|
|
const view = render(<HeroScene />);
|
|
|
|
view.unmount();
|
|
|
|
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("HeroScene: câmera que segue o mouse", () => {
|
|
// a cada renderização CameraRig e GridFloor chamam useFrame, nessa ordem: valem os dois últimos
|
|
const rigFrame = () => mockFrames[mockFrames.length - 2];
|
|
|
|
it("path: mouse at the center -> the camera eases toward the resting height and looks at the origin", () => {
|
|
render(<HeroScene />);
|
|
|
|
act(() => {
|
|
rigFrame()({}, 0.016);
|
|
});
|
|
|
|
expect(mockCamera.position.x).toBeCloseTo(0, 9);
|
|
expect(mockCamera.position.y).toBeCloseTo(0.5, 9);
|
|
expect(mockCamera.lookAt).toHaveBeenCalledWith(0, 0, 0);
|
|
});
|
|
|
|
it("path: mouse moved to the top-right corner -> the camera drifts right and up, a little per frame", () => {
|
|
Object.defineProperty(window, "innerWidth", {
|
|
configurable: true,
|
|
value: 1000,
|
|
});
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 800,
|
|
});
|
|
render(<HeroScene />);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new MouseEvent("mousemove", { clientX: 1000, clientY: 0 }),
|
|
);
|
|
rigFrame()({}, 0.016);
|
|
});
|
|
|
|
// alvo x = 1 * 0.5; alvo y = 1 * 0.3 + 0.5; passo de 2% por quadro
|
|
expect(mockCamera.position.x).toBeCloseTo(0.5 * 0.02, 9);
|
|
expect(mockCamera.position.y).toBeCloseTo(0.5 + (0.8 - 0.5) * 0.02, 9);
|
|
});
|
|
|
|
it("path: unmount -> the mouse listener is removed", () => {
|
|
const remove = jest.spyOn(window, "removeEventListener");
|
|
const view = render(<HeroScene />);
|
|
|
|
view.unmount();
|
|
|
|
expect(remove.mock.calls.map(([type]) => type)).toContain("mousemove");
|
|
});
|
|
});
|
|
|
|
describe("HeroScene: relógio do chão em grade", () => {
|
|
const gridFrame = () => mockFrames[mockFrames.length - 1];
|
|
|
|
function attachMaterial(material: unknown) {
|
|
const mesh = document.querySelector("mesh") as HTMLElement & {
|
|
material?: unknown;
|
|
};
|
|
mesh.material = material;
|
|
}
|
|
|
|
it("path: the mesh has shader uniforms -> uTime follows the clock", () => {
|
|
render(<HeroScene />);
|
|
const uniforms = { uTime: { value: 0 } };
|
|
attachMaterial({ uniforms });
|
|
|
|
gridFrame()({ clock: { elapsedTime: 12.5 } }, 0.016);
|
|
|
|
expect(uniforms.uTime.value).toBe(12.5);
|
|
});
|
|
|
|
it("path: the material has no uniforms yet -> nothing happens", () => {
|
|
render(<HeroScene />);
|
|
attachMaterial({});
|
|
|
|
expect(() =>
|
|
gridFrame()({ clock: { elapsedTime: 3 } }, 0.016),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("path: the mesh is gone (unmounted) -> the frame callback does nothing", () => {
|
|
const view = render(<HeroScene />);
|
|
const frame = gridFrame();
|
|
view.unmount();
|
|
|
|
expect(() => frame({ clock: { elapsedTime: 3 } }, 0.016)).not.toThrow();
|
|
});
|
|
});
|