Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
681 lines
20 KiB
TypeScript
681 lines
20 KiB
TypeScript
import { act, render, screen } from "@testing-library/react";
|
|
import { rememberSoftwareWebGL } from "@/lib/device";
|
|
import HeroField, { DIVE_QUERY } from "./HeroField";
|
|
|
|
/**
|
|
* 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
|
|
* (cena que não abre o contexto, pacote que não carrega), o WebGL por software lembrado na sessão, 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 = {
|
|
visible: boolean;
|
|
progressRef: { current: number };
|
|
onReady: () => void;
|
|
onError: () => void;
|
|
};
|
|
const mockScenes: SceneProps[] = [];
|
|
const mockPrepare = jest.fn();
|
|
let mockImportFails = false;
|
|
|
|
function MockScene(props: SceneProps) {
|
|
mockScenes.push(props);
|
|
return require("react").createElement("canvas", { "data-testid": "scene" });
|
|
}
|
|
|
|
jest.mock("./BlocosScene", () => ({
|
|
__esModule: true,
|
|
// 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 prepareBlocos() {
|
|
if (mockImportFails) throw new Error("chunk indisponível");
|
|
return () => {
|
|
mockPrepare();
|
|
return Promise.resolve();
|
|
};
|
|
},
|
|
}));
|
|
|
|
const win = window as unknown as Record<string, unknown>;
|
|
const original = {
|
|
matchMedia: window.matchMedia,
|
|
webgl2: win.WebGL2RenderingContext,
|
|
getContext: HTMLCanvasElement.prototype.getContext,
|
|
observer: global.IntersectionObserver,
|
|
idle: win.requestIdleCallback,
|
|
cancelIdle: win.cancelIdleCallback,
|
|
};
|
|
|
|
function mockMedia(active: string[] = [DIVE_QUERY]) {
|
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
|
matches: active.includes(query),
|
|
media: query,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
}));
|
|
}
|
|
|
|
function mockNavigator(values: Record<string, unknown>) {
|
|
for (const [key, value] of Object.entries(values))
|
|
Object.defineProperty(navigator, key, { configurable: true, value });
|
|
}
|
|
|
|
function setContext(factory: () => unknown) {
|
|
HTMLCanvasElement.prototype.getContext = factory as never;
|
|
}
|
|
|
|
type Observer = {
|
|
callback: IntersectionObserverCallback;
|
|
disconnect: jest.Mock;
|
|
};
|
|
let observers: Observer[] = [];
|
|
|
|
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
|
|
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
|
|
|
|
function Hero({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<section className="hero" style={HERO_TAIL}>
|
|
<HeroField />
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const section = () => document.querySelector(".hero") as HTMLElement;
|
|
|
|
function setHidden(value: boolean) {
|
|
Object.defineProperty(document, "hidden", { configurable: true, value });
|
|
}
|
|
|
|
function reportIntersecting(value: boolean) {
|
|
act(() => {
|
|
for (const observer of observers)
|
|
observer.callback(
|
|
[{ isIntersecting: value } as IntersectionObserverEntry],
|
|
{} as IntersectionObserver,
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
mockNavigator({
|
|
hardwareConcurrency: 8,
|
|
deviceMemory: undefined,
|
|
connection: undefined,
|
|
});
|
|
win.WebGL2RenderingContext = function WebGL2RenderingContext() {};
|
|
setContext(() => ({ getExtension: () => null }));
|
|
win.requestIdleCallback = undefined;
|
|
win.cancelIdleCallback = undefined;
|
|
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;
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
jest.restoreAllMocks();
|
|
window.matchMedia = original.matchMedia;
|
|
win.WebGL2RenderingContext = original.webgl2;
|
|
win.requestIdleCallback = original.idle;
|
|
win.cancelIdleCallback = original.cancelIdle;
|
|
HTMLCanvasElement.prototype.getContext = original.getContext;
|
|
global.IntersectionObserver = original.observer;
|
|
Reflect.deleteProperty(document, "hidden");
|
|
});
|
|
|
|
describe("HeroField: quando o WebGL monta", () => {
|
|
it("path: no requestIdleCallback -> the scene starts loading after 1.2 s of timer, not before", async () => {
|
|
render(<Hero />);
|
|
|
|
await advance(1199);
|
|
expect(mockPrepare).not.toHaveBeenCalled();
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
|
|
await advance(1);
|
|
await finishLoading();
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
});
|
|
|
|
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"));
|
|
});
|
|
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 />);
|
|
await advance(1300);
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
expect(request).toHaveBeenCalledWith(expect.any(Function), {
|
|
timeout: 4000,
|
|
});
|
|
|
|
act(() =>
|
|
waiting.callback?.({ didTimeout: false, timeRemaining: () => 45 }),
|
|
);
|
|
await finishLoading();
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
});
|
|
|
|
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 loads afterwards, and the gestures no longer matter", async () => {
|
|
const view = render(<Hero />);
|
|
view.unmount();
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new Event("pointermove"));
|
|
});
|
|
await advance(5000);
|
|
|
|
expect(mockPrepare).not.toHaveBeenCalled();
|
|
expect(mockScenes).toHaveLength(0);
|
|
});
|
|
|
|
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: the scene fails to open its WebGL context (it reports an error) -> static background, no dive, no scene", async () => {
|
|
render(<Hero />);
|
|
await advance(1200);
|
|
await finishLoading();
|
|
expect(section()).toHaveAttribute("data-dive");
|
|
|
|
act(() => mockScenes.at(-1)?.onError());
|
|
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
expect(section()).not.toHaveAttribute("data-dive");
|
|
});
|
|
|
|
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: HeroField never touches a canvas context, on hydration or when idle (a test context cost a 150 to 500 ms task)", async () => {
|
|
const probe = jest.fn(() => ({ getExtension: () => null }));
|
|
setContext(probe);
|
|
|
|
render(<Hero />);
|
|
expect(probe).not.toHaveBeenCalled();
|
|
await advance(1200);
|
|
await finishLoading();
|
|
|
|
expect(probe).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
});
|
|
|
|
it("path: low power -> the WebGL probe, the timer and the gesture listeners are never started", async () => {
|
|
mockNavigator({ hardwareConcurrency: 4 });
|
|
const probe = jest.fn(() => ({ getExtension: () => null }));
|
|
setContext(probe);
|
|
const addListener = jest.spyOn(window, "addEventListener");
|
|
|
|
render(<Hero />);
|
|
act(() => {
|
|
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 place(top: number, height = 2760) {
|
|
section().getBoundingClientRect = () =>
|
|
({ top, height, bottom: top + height }) as DOMRect;
|
|
}
|
|
|
|
function scroll(top: number) {
|
|
place(top);
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(40);
|
|
});
|
|
}
|
|
|
|
async function loaded() {
|
|
await advance(1200);
|
|
await finishLoading();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 800,
|
|
});
|
|
});
|
|
|
|
it("path: no .hero ancestor -> no scroll variables and no observer, and nothing else starts either", async () => {
|
|
render(<HeroField />);
|
|
await advance(3000);
|
|
await finishLoading();
|
|
|
|
expect(observers).toHaveLength(0);
|
|
expect(mockScenes).toHaveLength(0);
|
|
});
|
|
|
|
it("path: scrolling to the same progress twice -> the CSS variables are written once", () => {
|
|
render(<Hero />);
|
|
const setProperty = jest.spyOn(section().style, "setProperty");
|
|
|
|
scroll(-600);
|
|
const afterFirst = setProperty.mock.calls.length;
|
|
expect(afterFirst).toBeGreaterThan(0);
|
|
scroll(-600);
|
|
|
|
expect(setProperty.mock.calls).toHaveLength(afterFirst);
|
|
});
|
|
|
|
it("path: resize recomputes the progress like a scroll", () => {
|
|
render(<Hero />);
|
|
place(-700);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new Event("resize"));
|
|
jest.advanceTimersByTime(40);
|
|
});
|
|
|
|
// trilho de 1400 px (2760 de hero - 1,7 tela de 800)
|
|
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
|
0.5,
|
|
);
|
|
});
|
|
|
|
it("path: scroll above the hero (positive top) -> progress clamps at 0", () => {
|
|
render(<Hero />);
|
|
|
|
scroll(300);
|
|
|
|
expect(Number(section().style.getPropertyValue("--hero-p") || 0)).toBe(0);
|
|
});
|
|
|
|
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(() => {
|
|
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(-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", async () => {
|
|
render(<Hero />);
|
|
await loaded();
|
|
|
|
reportIntersecting(true);
|
|
expect(mockScenes.at(-1)?.visible).toBe(true);
|
|
|
|
reportIntersecting(false);
|
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
|
|
|
reportIntersecting(true);
|
|
expect(mockScenes.at(-1)?.visible).toBe(true);
|
|
});
|
|
|
|
it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", async () => {
|
|
render(<Hero />);
|
|
await loaded();
|
|
reportIntersecting(true);
|
|
|
|
setHidden(true);
|
|
act(() => {
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
|
|
|
setHidden(false);
|
|
act(() => {
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
expect(mockScenes.at(-1)?.visible).toBe(true);
|
|
});
|
|
|
|
it("path: the tab shows but the hero is off screen -> still not visible", async () => {
|
|
render(<Hero />);
|
|
await loaded();
|
|
reportIntersecting(false);
|
|
|
|
act(() => {
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
|
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
|
});
|
|
|
|
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();
|
|
|
|
const removed = removeListener.mock.calls.map(([type]) => type);
|
|
expect(removed).toEqual(expect.arrayContaining(["scroll", "resize"]));
|
|
expect(removeDocListener.mock.calls.map(([type]) => type)).toContain(
|
|
"visibilitychange",
|
|
);
|
|
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 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();
|
|
});
|
|
});
|
|
|
|
describe("HeroField: desmontagem em pontos que faltavam", () => {
|
|
it("path: unmounted after the geometry was prepared, before the Canvas -> the scene is never mounted", async () => {
|
|
const view = render(<Hero />);
|
|
mockPrepare.mockImplementationOnce(() => {
|
|
act(() => view.unmount());
|
|
});
|
|
|
|
await advance(1200);
|
|
await finishLoading();
|
|
|
|
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
|
expect(mockScenes).toHaveLength(0);
|
|
});
|
|
|
|
it("path: no dive on this screen (the dive query does not match) -> unmounting is quiet and the hero stays plain", async () => {
|
|
mockMedia([]);
|
|
const view = render(<Hero />);
|
|
await finishLoading();
|
|
const hero = section();
|
|
|
|
expect(() => view.unmount()).not.toThrow();
|
|
|
|
expect(hero).not.toHaveAttribute("data-dive");
|
|
});
|
|
});
|
|
|
|
describe("HeroField: WebGL por software (o veredito vem da cena e fica na sessão)", () => {
|
|
beforeEach(() => sessionStorage.clear());
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
sessionStorage.clear();
|
|
});
|
|
|
|
it("path: software WebGL already found in this session -> the plain 100svh hero from hydration: no dive, no scroll listeners, no observer, no scene", async () => {
|
|
rememberSoftwareWebGL();
|
|
const addListener = jest.spyOn(window, "addEventListener");
|
|
|
|
render(<Hero />);
|
|
act(() => {
|
|
window.dispatchEvent(new Event("pointermove"));
|
|
});
|
|
await advance(3000);
|
|
await finishLoading();
|
|
|
|
expect(section()).not.toHaveAttribute("data-dive");
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
expect(mockScenes).toHaveLength(0);
|
|
expect(mockPrepare).not.toHaveBeenCalled();
|
|
expect(observers).toHaveLength(0);
|
|
expect(addListener.mock.calls.map(([type]) => type)).not.toContain(
|
|
"scroll",
|
|
);
|
|
});
|
|
|
|
it("path: nothing known about this device yet -> the dive starts at hydration and the scene mounts", async () => {
|
|
render(<Hero />);
|
|
expect(section()).toHaveAttribute("data-dive");
|
|
|
|
await advance(1200);
|
|
await finishLoading();
|
|
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
});
|
|
|
|
it("path: sessionStorage blocked (privacy mode) -> counts as unknown, the scene still mounts", async () => {
|
|
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
|
throw new Error("SecurityError");
|
|
});
|
|
|
|
render(<Hero />);
|
|
await advance(1200);
|
|
await finishLoading();
|
|
|
|
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
|
expect(section()).toHaveAttribute("data-dive");
|
|
});
|
|
|
|
it("path: the scene finds software WebGL once it has opened its context (onError) -> back to the 100svh hero, no scene", async () => {
|
|
render(<Hero />);
|
|
await advance(1200);
|
|
await finishLoading();
|
|
expect(section()).toHaveAttribute("data-dive");
|
|
|
|
act(() => mockScenes.at(-1)?.onError());
|
|
|
|
expect(section()).not.toHaveAttribute("data-dive");
|
|
expect(screen.queryByTestId("scene")).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
["missing from the stylesheet", undefined],
|
|
["not a number", "abc"],
|
|
])(
|
|
"path: --hero-tail %s -> no tail, the rail is the whole hero minus one screen (1960 px, never NaN)",
|
|
(_name, tail) => {
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 800,
|
|
});
|
|
const style = tail
|
|
? ({ "--hero-tail": tail } as React.CSSProperties)
|
|
: undefined;
|
|
render(
|
|
<section className="hero" style={style}>
|
|
<HeroField />
|
|
</section>,
|
|
);
|
|
section().getBoundingClientRect = () =>
|
|
({ top: -700, height: 2760, bottom: 2060 }) as DOMRect;
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new Event("resize"));
|
|
jest.advanceTimersByTime(40);
|
|
});
|
|
|
|
// 2760 - 800 = 1960 de trilho; com a cauda de 1,7 tela (o CSS real) seria 1400 e o progresso 0,5
|
|
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
|
700 / 1960,
|
|
3,
|
|
);
|
|
},
|
|
);
|
|
});
|