diff --git a/app/components/cinematic/CinematicOverlays.paths.test.tsx b/app/components/cinematic/CinematicOverlays.paths.test.tsx index 4be94b2..b9c5568 100644 --- a/app/components/cinematic/CinematicOverlays.paths.test.tsx +++ b/app/components/cinematic/CinematicOverlays.paths.test.tsx @@ -1,4 +1,5 @@ import { act, render, screen } from "@testing-library/react"; +import { setNavigation } from "@/__mocks__/next/navigation"; import CinematicOverlays from "./CinematicOverlays"; /** @@ -6,9 +7,6 @@ import CinematicOverlays from "./CinematicOverlays"; * com a página montada (de fora da home para a home e de volta) e as duas condições do grão juntas. */ -let mockPathname = "/"; -jest.mock("next/navigation", () => ({ usePathname: () => mockPathname })); - const mockLoaded = jest.fn(); jest.mock("./FilmGrain", () => { mockLoaded(); @@ -38,7 +36,7 @@ function mockMedia(active: string[] = []) { } beforeEach(() => { - mockPathname = "/"; + setNavigation({ pathname: "/" }); mockLoaded.mockClear(); mockMedia(); }); @@ -49,12 +47,12 @@ afterEach(() => { describe("CinematicOverlays: troca de página", () => { it("path: arriving on the home from another page with a mouse -> the grain and the progress bar appear", async () => { - mockPathname = "/cases"; + setNavigation({ pathname: "/cases" }); const { container, rerender } = render(); await loaded(); expect(container).toBeEmptyDOMElement(); - mockPathname = "/"; + setNavigation({ pathname: "/" }); rerender(); await loaded(); @@ -67,12 +65,12 @@ describe("CinematicOverlays: troca de página", () => { await loaded(); expect(screen.getByTestId("grain")).toBeInTheDocument(); - mockPathname = "/servicos"; + setNavigation({ pathname: "/servicos" }); rerender(); expect(screen.queryByTestId("grain")).toBeNull(); expect(container).toBeEmptyDOMElement(); - mockPathname = "/"; + setNavigation({ pathname: "/" }); rerender(); await loaded(); expect(screen.getByTestId("grain")).toBeInTheDocument(); @@ -80,7 +78,7 @@ describe("CinematicOverlays: troca de página", () => { }); it("path: a sub-route that merely starts like the home path -> not the home", async () => { - mockPathname = "/cases/sev-modaintima"; + setNavigation({ pathname: "/cases/sev-modaintima" }); const { container } = render(); await loaded(); diff --git a/app/components/cinematic/CinematicOverlays.test.tsx b/app/components/cinematic/CinematicOverlays.test.tsx index 80bca8f..f51cf44 100644 --- a/app/components/cinematic/CinematicOverlays.test.tsx +++ b/app/components/cinematic/CinematicOverlays.test.tsx @@ -1,9 +1,7 @@ import { act, render, screen } from "@testing-library/react"; +import { setNavigation } from "@/__mocks__/next/navigation"; import CinematicOverlays from "./CinematicOverlays"; -let mockPathname = "/"; -jest.mock("next/navigation", () => ({ usePathname: () => mockPathname })); - // o grão vem por import dinâmico (pacote próprio): aqui ele vira um marcador, e o contador diz se o pacote foi pedido const mockLoaded = jest.fn(); jest.mock("./FilmGrain", () => { @@ -34,7 +32,7 @@ function mockMedia(active: string[] = []) { } beforeEach(() => { - mockPathname = "/"; + setNavigation({ pathname: "/" }); mockMedia(); }); @@ -68,7 +66,7 @@ describe("CinematicOverlays: where the grain package is never requested", () => }); it("shows nothing, and requests nothing, on the other pages", async () => { - mockPathname = "/cases"; + setNavigation({ pathname: "/cases" }); const { container } = render(); await loaded(); diff --git a/app/components/cinematic/FramePulse.ts b/app/components/cinematic/FramePulse.ts index 61240b9..adafb07 100644 --- a/app/components/cinematic/FramePulse.ts +++ b/app/components/cinematic/FramePulse.ts @@ -19,8 +19,9 @@ export type FramePulseOptions = { * Batida das cenas WebGL em demanda: pede um quadro (`invalidate`) a no máximo `fps` por segundo, de um único * `setInterval` (fundo ambiente, sem gastar 60 quadros por segundo e sem um segundo laço de requestAnimationFrame ao * lado do da cena). Depois de `idleMs` sem cursor, rolagem nem teclado, a batida para de vez (nenhum intervalo, nenhum - * quadro) e o último quadro fica na tela; qualquer movimento a religa. Quem precisa de mais (entrada, rolagem, cursor) - * pede o próximo quadro por conta própria, dentro do quadro. Devolve a função que desliga tudo. + * quadro) e o último quadro fica na tela; qualquer movimento a religa. É o único relógio do cursor (nenhum + * requestAnimationFrame encadeado a cada quadro da tela); quem precisa de mais (entrada, rolagem) pede o próximo quadro + * por conta própria, dentro do quadro. Devolve a função que desliga tudo. */ export function startFramePulse( invalidate: () => void, diff --git a/app/components/cinematic/HeroField.paths.test.tsx b/app/components/cinematic/HeroField.paths.test.tsx index f93847d..c1285a2 100644 --- a/app/components/cinematic/HeroField.paths.test.tsx +++ b/app/components/cinematic/HeroField.paths.test.tsx @@ -1,10 +1,11 @@ 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 - * (contexto WebGL2, pacote que não carrega), o fundo sem a seção `.hero`, as variáveis de rolagem e a + * (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. */ @@ -306,25 +307,13 @@ describe("HeroField: quando o WebGL monta", () => { }); 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"); - }); - + it("path: the scene fails to open its WebGL context (it reports an error) -> static background, no dive, no scene", async () => { render(); await advance(1200); await finishLoading(); + expect(section()).toHaveAttribute("data-dive"); - expect(screen.queryByTestId("scene")).toBeNull(); - expect(section()).not.toHaveAttribute("data-dive"); - }); - - it("path: WebGL2 constructor exists but the context is null -> static background, no dive", async () => { - setContext(() => null); - - render(); - await advance(1200); - await finishLoading(); + act(() => mockScenes.at(-1)?.onError()); expect(screen.queryByTestId("scene")).toBeNull(); expect(section()).not.toHaveAttribute("data-dive"); @@ -342,28 +331,16 @@ describe("HeroField: falhas voltam ao hero simples", () => { 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) => - name === "WEBGL_lose_context" ? { loseContext } : null, - })); - - render(); - 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", async () => { - setContext(() => ({ getExtension: () => null })); + 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(); + expect(probe).not.toHaveBeenCalled(); await advance(1200); await finishLoading(); + expect(probe).not.toHaveBeenCalled(); expect(screen.getByTestId("scene")).toBeInTheDocument(); }); @@ -601,61 +578,48 @@ describe("HeroField: desmontagem em pontos que faltavam", () => { }); }); -/** Um contexto WebGL de teste que conta ao navegador o nome do renderizador (WEBGL_debug_renderer_info). */ -function contextRenderedBy(renderer: unknown, loseContext = jest.fn()) { - const RENDERER = 37446; - return { - getExtension: (name: string) => { - if (name === "WEBGL_debug_renderer_info") - return { UNMASKED_RENDERER_WEBGL: RENDERER }; - return name === "WEBGL_lose_context" ? { loseContext } : null; - }, - getParameter: (parameter: number) => - parameter === RENDERER ? renderer : undefined, - }; -} +describe("HeroField: WebGL por software (o veredito vem da cena e fica na sessão)", () => { + beforeEach(() => sessionStorage.clear()); + afterEach(() => { + jest.restoreAllMocks(); + sessionStorage.clear(); + }); -describe("HeroField: WebGL2 por software (supportsWebGL2 -> isSoftwareWebGL)", () => { - it.each([ - [ - "SwiftShader (Chrome sem GPU)", - "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)", - ], - ["llvmpipe (Mesa)", "llvmpipe (LLVM 15.0.7, 256 bits)"], - [ - "Microsoft Basic Render Driver", - "ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)", - ], - ])( - "path: %s -> the plain 100svh hero from hydration: no dive, no scroll listeners, no observer, no scene, and the probe context is still released", - async (_name, renderer) => { - const loseContext = jest.fn(); - setContext(() => contextRenderedBy(renderer, loseContext)); - const addListener = jest.spyOn(window, "addEventListener"); + 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(); - act(() => { - window.dispatchEvent(new Event("pointermove")); - }); - await advance(3000); - await finishLoading(); + render(); + 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", - ); - expect(loseContext).toHaveBeenCalledTimes(1); - }, - ); - - it("path: a hardware renderer -> the scene mounts and the dive starts", async () => { - setContext(() => - contextRenderedBy("ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11)"), + 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(); + 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(); await advance(1200); @@ -665,24 +629,16 @@ describe("HeroField: WebGL2 por software (supportsWebGL2 -> isSoftwareWebGL)", ( expect(section()).toHaveAttribute("data-dive"); }); - it("path: no renderer info extension (Firefox, Safari) -> cannot tell, so the scene mounts", async () => { - setContext(() => ({ getExtension: () => null })); - + it("path: the scene finds software WebGL once it has opened its context (onError) -> back to the 100svh hero, no scene", async () => { render(); await advance(1200); await finishLoading(); + expect(section()).toHaveAttribute("data-dive"); - expect(screen.getByTestId("scene")).toBeInTheDocument(); - }); + act(() => mockScenes.at(-1)?.onError()); - it("path: the renderer name is not a string -> cannot tell, so the scene mounts", async () => { - setContext(() => contextRenderedBy(null)); - - render(); - await advance(1200); - await finishLoading(); - - expect(screen.getByTestId("scene")).toBeInTheDocument(); + expect(section()).not.toHaveAttribute("data-dive"); + expect(screen.queryByTestId("scene")).toBeNull(); }); it.each([ diff --git a/app/components/cinematic/HeroField.test.tsx b/app/components/cinematic/HeroField.test.tsx index a68e8b9..0eb6b33 100644 --- a/app/components/cinematic/HeroField.test.tsx +++ b/app/components/cinematic/HeroField.test.tsx @@ -1,4 +1,5 @@ import { act, render, screen } from "@testing-library/react"; +import { rememberSoftwareWebGL } from "@/lib/device"; import HeroField, { DIVE_QUERY } from "./HeroField"; type SceneProps = { @@ -38,18 +39,6 @@ function mockMedia(active: string[] = [DIVE_QUERY]) { })); } -/** Um WebGL2 que abre, e que diz na extensão de depuração em que renderizador desenha. */ -function mockWebGLRenderer(renderer: string) { - HTMLCanvasElement.prototype.getContext = (() => ({ - getExtension: (name: string) => - name === "WEBGL_debug_renderer_info" - ? { UNMASKED_RENDERER_WEBGL: 37446 } - : null, - getParameter: (parameter: number) => - parameter === 37446 ? renderer : null, - })) as unknown as typeof HTMLCanvasElement.prototype.getContext; -} - function mockNavigator(values: Record) { for (const [key, value] of Object.entries(values)) Object.defineProperty(navigator, key, { configurable: true, value }); @@ -94,6 +83,7 @@ async function idle() { beforeEach(() => { jest.useFakeTimers(); + sessionStorage.clear(); mockScenes.length = 0; mockPrepare.mockClear(); mockMedia(); @@ -111,6 +101,7 @@ beforeEach(() => { afterEach(() => { jest.useRealTimers(); + sessionStorage.clear(); window.matchMedia = original.matchMedia; (window as unknown as Record).WebGL2RenderingContext = original.webgl2; @@ -129,6 +120,19 @@ describe("HeroField (ambient background)", () => { expect(mockScenes.at(-1)?.visible).toBe(true); }); + it("opens no WebGL context of its own, on hydration or when idle: the only one is the scene's", async () => { + const probe = jest.fn(() => ({ getExtension: () => null })); + HTMLCanvasElement.prototype.getContext = + probe as unknown as typeof HTMLCanvasElement.prototype.getContext; + + render(); + expect(probe).not.toHaveBeenCalled(); + await idle(); + + expect(probe).not.toHaveBeenCalled(); + expect(screen.getByTestId("scene")).toBeInTheDocument(); + }); + it("brings the geometry in before the Canvas, in a task of its own", async () => { render(); @@ -183,17 +187,11 @@ describe("HeroField (ambient background)", () => { undefined; }, ], - ...[ - "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)", - "llvmpipe (LLVM 15.0.7, 256 bits)", - "ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)", - ].map( - (renderer) => - [ - `software WebGL (${renderer.slice(0, 24)}...)`, - () => mockWebGLRenderer(renderer), - ] as [string, () => void], - ), + // o veredito vem do contexto da cena (HeroFieldScene), que o anota na sessão; a página seguinte já nasce estática + [ + "a software WebGL found earlier in the session", + () => rememberSoftwareWebGL(), + ], ])( "keeps the light static background (no WebGL, no dive, no scroll work) with %s", async (_label, arrange) => { diff --git a/app/components/cinematic/HeroField.tsx b/app/components/cinematic/HeroField.tsx index 70b6ef9..0b9409a 100644 --- a/app/components/cinematic/HeroField.tsx +++ b/app/components/cinematic/HeroField.tsx @@ -7,7 +7,7 @@ import { useRef, useState, } from "react"; -import { isLowPowerDevice, isSoftwareWebGL } from "@/lib/device"; +import { isKnownSoftwareWebGL, isLowPowerDevice } from "@/lib/device"; import type { HeroFieldSceneProps } from "./HeroFieldScene"; /** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */ @@ -44,21 +44,12 @@ const smooth = (a: number, b: number, x: number) => { }; /** - * O aparelho abre um contexto WebGL2 de verdade, na GPU. Um WebGL por software (SwiftShader, llvmpipe, Microsoft - * Basic Render) abre, mas desenha a cena a poucos quadros por segundo na CPU: nesse caso vale o hero estático. + * O navegador tem a API do WebGL2? É só uma leitura: abrir um contexto de teste aqui, na hidratação, custava uma tarefa + * de 150 ms (500 ms por software). Se o WebGL2 abre de verdade, e se ele desenha na GPU, quem diz é a própria cena, + * no contexto que ela abre para desenhar (HeroFieldScene.createRenderer); o veredito "por software" fica anotado na + * sessão (isKnownSoftwareWebGL), e as páginas seguintes já nascem estáticas. */ -function supportsWebGL2() { - if (typeof window.WebGL2RenderingContext === "undefined") return false; - try { - const context = document.createElement("canvas").getContext("webgl2"); - if (!context) return false; - const software = isSoftwareWebGL(context); - context.getExtension("WEBGL_lose_context")?.loseContext(); - return !software; - } catch { - return false; - } -} +const hasWebGL2 = () => typeof window.WebGL2RenderingContext !== "undefined"; /** Um intervalo ocioso do navegador (sem requestIdleCallback, um instante depois). */ function nextIdle() { @@ -183,7 +174,9 @@ function trackVisibility( * outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem * e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica * ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho - * (trackDive). Sem legendas sobre o 3D: o espaço da direita é das molduras. + * (trackDive). O desenho por software só se descobre no contexto que a cena abre (nunca num contexto de teste na + * hidratação): ela avisa por `onError` e o hero volta ao palco estático. Sem legendas sobre o 3D: o espaço da direita + * é das molduras. */ export default function HeroField() { const rootRef = useRef(null); @@ -202,9 +195,10 @@ export default function HeroField() { if (isLowPowerDevice()) return; const section = rootRef.current?.closest(".hero"); if (!section) return; - // WebGL2 só por software (ou nenhum): o hero estático de 100svh, já na hidratação, sem o bloco de rolagem do - // mergulho que depois seria desfeito com a página já rolada - if (!supportsWebGL2()) return; + // sem WebGL2, ou com o desenho por software já descoberto nesta sessão: o hero estático de 100svh, já na hidratação, + // sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai do contexto + // da cena, ocioso, sem uma tarefa longa na hidratação); sendo software, a cena avisa e o hero volta a 100svh + if (!hasWebGL2() || isKnownSoftwareWebGL()) return; let alive = true; const stopDive = window.matchMedia?.(DIVE_QUERY).matches ? trackDive(section, progressRef, setDone) diff --git a/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx b/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx index cf339c2..6b25c0b 100644 --- a/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx +++ b/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx @@ -20,6 +20,8 @@ class MockRenderer { forceContextLoss = jest.fn(); setSize = jest.fn(); setPixelRatio = jest.fn(); + // um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software + getContext = () => ({ getExtension: () => null }); getPixelRatio = () => 1; constructor() { mockRenderers.push(this); diff --git a/app/components/cinematic/HeroFieldScene.paths.test.tsx b/app/components/cinematic/HeroFieldScene.paths.test.tsx index 317b6a2..126d11d 100644 --- a/app/components/cinematic/HeroFieldScene.paths.test.tsx +++ b/app/components/cinematic/HeroFieldScene.paths.test.tsx @@ -10,7 +10,7 @@ import { import { createField, type FieldEngine, - needsFrame, + isSettling, prepareField, type Rig, type Tick, @@ -23,7 +23,7 @@ import { * - onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente), entrada, escurecimento e abertura; * - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente; * - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve); - * - quando pedir o próximo quadro (entrada, rolagem assentando, cursor) e quando não pedir; + * - quando pedir o próximo quadro (entrada e rolagem assentando) e quando deixar para a batida de 30 por segundo (cursor); * - perda do contexto WebGL, falha ao desenhar e limpeza. * O WebGLRenderer é um dublê (o jsdom não tem WebGL); o `three` é o de verdade, com ShaderMaterial, BufferGeometry, * PerspectiveCamera e Group registrando as instâncias criadas. O quadro é chamado direto por `renderAt`, com o relógio @@ -48,6 +48,8 @@ class MockRenderer { forceContextLoss = jest.fn(); setSize = jest.fn(); setPixelRatio = jest.fn(); + // um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software + getContext = () => ({ getExtension: () => null }); getPixelRatio = () => this.pixelRatio; constructor() { mockRenderers.push(this); @@ -512,13 +514,16 @@ describe("createField: quando pedir o próximo quadro", () => { expect(scene.frame(50)).toBe(0); }); - it("path: settled, visible, and the mouse is moving -> frames are requested", () => { + it("path: settled, visible, and the mouse is moving -> the frame draws but asks for no other (the pulse brings the next at 30 Hz)", () => { const scene = setup({ visible: true }); scene.settle(200); (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); + const drawn = scene.renderer.render.mock.calls.length; - expect(scene.frame(50)).toBeGreaterThan(0); + expect(scene.frame(50)).toBe(0); + + expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1); }); it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => { @@ -538,16 +543,17 @@ describe("createField: quando pedir o próximo quadro", () => { expect(scene.frame()).toBeGreaterThan(0); }); - it("path: becoming visible later -> the next frame sees the new flag", () => { + it("path: becoming visible later -> the pulse starts and asks for a frame right away", () => { const scene = setup({ visible: false }); scene.settle(200); (performance.now as jest.Mock).mockReturnValue(scene.now()); pointerMove("mouse"); expect(scene.frame(50)).toBe(0); + frameRequests.mockClear(); scene.engine.setVisible(true); - expect(scene.frame(50)).toBeGreaterThan(0); + expect(frameRequests).toHaveBeenCalledTimes(1); }); it("path: several requests before a frame become ONE animation frame", () => { @@ -563,7 +569,7 @@ describe("createField: quando pedir o próximo quadro", () => { }); }); -describe("needsFrame / updatePointer / updateRig (contas puras)", () => { +describe("isSettling / updatePointer / updateRig (contas puras)", () => { function rig(): Rig { const scene = setup(); scene.frame(); @@ -604,23 +610,22 @@ describe("needsFrame / updatePointer / updateRig (contas puras)", () => { ...overrides, }); - it("needsFrame: settling wins over everything, interaction only counts on screen", () => { + it("isSettling: the intro still running keeps asking, the settled scene does not", () => { const r = rig(); r.uniforms.uIntro.value = 1; - r.uniforms.uMouseAmt.value = 0.4; - expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(false); - expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, true)).toBe(true); - expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, false)).toBe(false); - expect(needsFrame(r.uniforms, r.state, 0.5, 0.4, false, false)).toBe(true); + expect(isSettling(r.uniforms, r.state, 0)).toBe(false); r.uniforms.uIntro.value = 0.5; - expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, false)).toBe(true); + expect(isSettling(r.uniforms, r.state, 0)).toBe(true); }); - it("needsFrame: the first frames always ask for another (the cursor strength still has to settle)", () => { + it("isSettling: the scroll target far from the shown progress keeps asking, the cursor does not (the pulse does that)", () => { const r = rig(); r.uniforms.uIntro.value = 1; + r.uniforms.uMouseAmt.value = 0; r.state.frames = 1; - expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(true); + r.state.lastMove = 1; + expect(isSettling(r.uniforms, r.state, 0)).toBe(false); + expect(isSettling(r.uniforms, r.state, 0.5)).toBe(true); }); it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => { @@ -648,30 +653,12 @@ describe("needsFrame / updatePointer / updateRig (contas puras)", () => { expect(r.group.rotation.y).toBeCloseTo(0, 9); }); - it("needsFrame: the cursor strength alone still far from its target asks for a frame, but only on screen", () => { + it("isSettling: the scroll damping counts as settling only beyond 0.0004 of the target", () => { const r = rig(); r.uniforms.uIntro.value = 1; - r.uniforms.uMouseAmt.value = 0; - - expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(true); - expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, false)).toBe(false); - // dentro da tolerância de 0,01 já está assentado - r.uniforms.uMouseAmt.value = 0.795; - expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(false); - }); - - it("needsFrame: the scroll damping counts as settling only beyond 0.0004 of the target", () => { - const r = rig(); - r.uniforms.uIntro.value = 1; - r.uniforms.uMouseAmt.value = 0.4; r.state.progress = 0.5; - - expect(needsFrame(r.uniforms, r.state, 0.50039, 0.4, false, false)).toBe( - false, - ); - expect(needsFrame(r.uniforms, r.state, 0.5005, 0.4, false, false)).toBe( - true, - ); + expect(isSettling(r.uniforms, r.state, 0.50039)).toBe(false); + expect(isSettling(r.uniforms, r.state, 0.5005)).toBe(true); }); it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => { @@ -856,12 +843,45 @@ describe("createField: o cursor sozinho não passa de ~30 quadros por segundo", return scene.renderer.render.mock.calls.length - before; } - function withMovingCursor() { + /** + * Uma tela de `refreshMs` entre quadros, com tudo assentado e o cursor mexendo: o único relógio é a batida (o + * setInterval de 33 ms do FramePulse, aqui tocado à mão), e um quadro da tela só roda se alguém o pediu. + */ + function pulsedScreen(refreshMs: number) { + let beat = () => {}; + jest.spyOn(window, "setInterval").mockImplementation((( + callback: () => void, + ) => { + beat = callback; + return 7; + }) as never); + jest.spyOn(window, "clearInterval").mockImplementation(() => {}); + let pending: FrameRequestCallback | undefined; + frameRequests.mockImplementation(((callback: FrameRequestCallback) => { + pending = callback; + return 1; + }) as never); const scene = setup({ visible: true }); - scene.settle(200); - (performance.now as jest.Mock).mockReturnValue(scene.now()); + let clock = 0; + let nextBeat = 33; + const run = (seconds: number) => { + const drawnBefore = scene.renderer.render.mock.calls.length; + frameRequests.mockClear(); + const end = clock + seconds * 1000; + for (clock += refreshMs; clock <= end; clock += refreshMs) { + for (; nextBeat <= clock; nextBeat += 33) beat(); + const frame = pending; + pending = undefined; + frame?.(1000 + clock); + } + return { + drawn: scene.renderer.render.mock.calls.length - drawnBefore, + requested: frameRequests.mock.calls.length, + }; + }; + run(6); // a entrada corre a cada quadro da tela e assenta pointerMove("mouse"); - return scene; + return run; } it.each([ @@ -869,32 +889,25 @@ describe("createField: o cursor sozinho não passa de ~30 quadros por segundo", [120, 1000 / 120], [60, 1000 / 60], ])( - "path: a %i Hz screen with the cursor moving -> about 30 frames a second, not one per screen frame", + "path: a %i Hz screen with the cursor moving -> about 30 frames a second drawn and asked for, never a wake-up per screen frame", (_hz, refreshMs) => { - const scene = withMovingCursor(); + const run = pulsedScreen(refreshMs); - const drawn = drawnOn(scene, refreshMs, 2); + const { drawn, requested } = run(2); expect(drawn).toBeGreaterThanOrEqual(2 * 25); expect(drawn).toBeLessThanOrEqual(2 * 31); + // quem pede é a batida: nenhum requestAnimationFrame para pular o desenho e pedir outro + expect(requested).toBeLessThanOrEqual(drawn + 1); }, ); - it("path: a skipped frame draws nothing, keeps the clock of the last drawn one and asks for the next", () => { - const scene = withMovingCursor(); - scene.frame(50); // o último desenhado - const drawn = scene.renderer.render.mock.calls.length; - - expect(scene.frame(10)).toBe(1); // 10 ms depois: pula e pede outro - expect(scene.renderer.render.mock.calls.length).toBe(drawn); - - expect(scene.frame(10)).toBe(1); - expect(scene.frame(10)).toBe(1); - expect(scene.renderer.render.mock.calls.length).toBe(drawn); - - // 40 ms depois do último desenhado: sai (o relógio do quadro pulado não conta) - scene.frame(10); - expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1); + it("path: a frame the pulse asks for soon after another is drawn, not skipped (nothing throttles it any more)", () => { + const run = pulsedScreen(1000 / 144); + const drawn = run(0.5).drawn; + expect(drawn).toBeGreaterThanOrEqual(14); + // 27,8 ms (4 quadros de 144 Hz) entre duas batidas que se alinharam mal: o desenho sai do mesmo jeito + expect(run(0.1).drawn).toBeGreaterThanOrEqual(2); }); it("path: the intro and the scroll are not throttled (one frame per screen frame while they settle)", () => { diff --git a/app/components/cinematic/HeroFieldScene.test.tsx b/app/components/cinematic/HeroFieldScene.test.tsx index 1fcd5c1..5bd9422 100644 --- a/app/components/cinematic/HeroFieldScene.test.tsx +++ b/app/components/cinematic/HeroFieldScene.test.tsx @@ -1,4 +1,5 @@ import { act, render } from "@testing-library/react"; +import { isKnownSoftwareWebGL } from "@/lib/device"; import HeroFieldScene from "./HeroFieldScene"; /* @@ -15,6 +16,8 @@ type RendererOptions = { }; const mockRenderers: MockRenderer[] = []; let mockFailToCreate = false; +/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */ +let mockRendererName: string | null = null; /** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */ let mockCompile: () => Promise = () => Promise.resolve(); @@ -31,6 +34,14 @@ class MockRenderer { this.pixelRatio = value; }; getPixelRatio = () => this.pixelRatio; + getContext = () => ({ + getExtension: (name: string) => + name === "WEBGL_debug_renderer_info" && mockRendererName !== null + ? { UNMASKED_RENDERER_WEBGL: 37446 } + : null, + getParameter: (parameter: number) => + parameter === 37446 ? mockRendererName : null, + }); constructor(options: RendererOptions) { if (mockFailToCreate) throw new Error("sem WebGL"); this.options = options; @@ -64,6 +75,7 @@ beforeEach(() => { jest.useFakeTimers(); mockRenderers.length = 0; mockFailToCreate = false; + mockRendererName = null; mockCompile = () => Promise.resolve(); setDevicePixelRatio(1); }); @@ -406,3 +418,54 @@ describe("HeroFieldScene (ambient 3D)", () => { }); }); }); + +describe("HeroFieldScene: WebGL por software (o teste é o contexto da própria cena)", () => { + beforeEach(() => sessionStorage.clear()); + afterEach(() => sessionStorage.clear()); + + it.each([ + "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)", + "llvmpipe (LLVM 15.0.7, 256 bits)", + "ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)", + ])( + "draws on the CPU (%s): no canvas, the page is told once, the context is freed and the session remembers it", + async (name) => { + mockRendererName = name; + const scene = props(); + + const { container } = await mount(); + + // um só contexto: o do teste é o que a cena abriria para desenhar, sem um segundo + expect(mockRenderers).toHaveLength(1); + expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1); + expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1); + expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled(); + expect(container.querySelector("canvas")).toBeNull(); + expect(scene.onError).toHaveBeenCalledTimes(1); + expect(scene.onReady).not.toHaveBeenCalled(); + expect(isKnownSoftwareWebGL()).toBe(true); + }, + ); + + it("draws on the GPU: the canvas stays and the session is not marked", async () => { + mockRendererName = + "ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)"; + const scene = props(); + + const { container } = await mount(); + + expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement); + expect(scene.onError).not.toHaveBeenCalled(); + expect(isKnownSoftwareWebGL()).toBe(false); + }); + + it("does not report an unknown renderer (no debug extension) as software", async () => { + mockRendererName = null; + const scene = props(); + + await mount(); + + expect(scene.onError).not.toHaveBeenCalled(); + expect(isKnownSoftwareWebGL()).toBe(false); + }); +}); diff --git a/app/components/cinematic/HeroFieldScene.tsx b/app/components/cinematic/HeroFieldScene.tsx index 14feab4..bc98fa4 100644 --- a/app/components/cinematic/HeroFieldScene.tsx +++ b/app/components/cinematic/HeroFieldScene.tsx @@ -20,6 +20,7 @@ import { Vector3, WebGLRenderer, } from "three"; +import { isSoftwareWebGL, rememberSoftwareWebGL } from "@/lib/device"; import { startFramePulse } from "./FramePulse"; export type HeroFieldSceneProps = { @@ -42,8 +43,8 @@ export type HeroFieldSceneProps = { * É um fundo ambiente: HeroField o deixa com pouca opacidade e uma máscara que o afasta do título e das * molduras da direita. O Three.js é usado direto (sem React Three Fiber, que levava junto o reconciliador do React * só para uma malha e um shader). O desenho é sob demanda: a cena pede um quadro a 30 por segundo (FramePulse, um - * setInterval) e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos - * sem cursor nem rolagem, não desenha nada. + * setInterval) e no ritmo da tela só enquanto a entrada ou a rolagem assenta; sem estar à vista, ou depois de uns + * segundos sem cursor nem rolagem, não desenha nada. */ type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados @@ -62,15 +63,19 @@ class FieldBuilder { x: number, y: number, z: number, - style: PointStyle = {}, + style?: PointStyle, to?: [number, number, number], ) { this.base.push(x, y, z); this.to.push(...(to ?? [x, y, z])); this.move.push(to ? 1 : 0); - this.tone.push(style.tone ?? 0); - this.size.push(style.size ?? 0.02); - this.alpha.push(style.alpha ?? 0.7); + this.pushStyle(style); + } + /** O tom, o tamanho e a opacidade do ponto, com o padrão onde o estilo não diz. */ + private pushStyle({ tone = 0, size = 0.02, alpha = 0.7 }: PointStyle = {}) { + this.tone.push(tone); + this.size.push(size); + this.alpha.push(alpha); } line( y: Layer, @@ -607,31 +612,14 @@ export function isSettling(u: Uniforms, s: FieldState, target: number) { return u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4; } -/** - * Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem - * rolagem); o quadro pede o seguinte (no ritmo da tela, até 60) só enquanto algo ainda muda. A entrada e a rolagem - * terminam de assentar mesmo depois de o hero sair de cena (o último quadro tem de ser o final); o cursor só conta - * com o hero à vista. +/* + * Sob demanda, com um só relógio para o que é ambiente: o FramePulse (um setInterval de 30 por segundo, ligado só com o + * hero à vista e parado depois de uns segundos sem cursor nem rolagem) pede o quadro do cursor e da onda dele; a tela, + * a 60 ou 144 Hz, não pede nada por conta própria. Um quadro só pede o seguinte de imediato, no ritmo da tela, enquanto + * a entrada ou a rolagem assenta (`isSettling`), mesmo com o hero fora de cena: o último quadro tem de ser o final. + * Antes, o cursor também encadeava um requestAnimationFrame por quadro da tela (143 por segundo a 144 Hz) só para + * pular o desenho e pedir de novo, e a página acordava sem desenhar nada. */ -export function needsFrame( - u: Uniforms, - s: FieldState, - target: number, - amount: number, - cursorActive: boolean, - visible: boolean, -) { - const interacting = - cursorActive || Math.abs(amount - u.uMouseAmt.value) > 0.01 || s.frames < 3; - return isSettling(u, s, target) || (visible && interacting); -} - -/** - * Intervalo mínimo (ms) entre dois quadros quando só o cursor os pede: uns 30 por segundo, mesmo numa tela de 144 Hz - * (que desenhava 142). Fica um pouco abaixo de 33,3 (dois quadros de uma tela de 60 Hz) para a tela de 60 Hz manter - * 30 e a batida (um setInterval de 33 ms, com a folga do temporizador) não perder quadros. - */ -const CURSOR_FRAME_MS = 32; /** O material de pontos: o shader, a mistura aditiva e os uniforms de partida. */ function createMaterial() { @@ -695,13 +683,23 @@ export type FieldEngine = { dispose: () => void; }; -/** O renderizador transparente, sem antialias, na GPU de alto desempenho, com o canvas dentro de `host`. */ +/** + * O renderizador transparente, sem antialias, na GPU de alto desempenho, com o canvas dentro de `host`. Lança se o + * contexto não abre ou se ele desenha por software (SwiftShader, llvmpipe, Basic Render): abre, mas a cena sairia a + * poucos quadros por segundo na CPU, e o hero fica estático. O contexto que acabou de abrir é o do teste (nenhum outro + * é criado só para isso) e o veredito fica anotado na sessão (`rememberSoftwareWebGL`). + */ function createRenderer(host: HTMLElement) { const renderer = new WebGLRenderer({ alpha: true, antialias: false, powerPreference: "high-performance", }); + if (isSoftwareWebGL(renderer.getContext())) { + rememberSoftwareWebGL(); + disposeRenderer(renderer); + throw new Error("WebGL por software"); + } renderer.setPixelRatio(pixelRatio()); const canvas = renderer.domElement; canvas.setAttribute("aria-hidden", "true"); @@ -733,24 +731,24 @@ function buildWorld(): World { return { scene, camera, group, geometry, material }; } -/** O que o quadro lê de fora: o progresso da rolagem, se o hero está à vista e o relógio. */ +/** O que o quadro lê de fora: o progresso da rolagem e o relógio. */ type StepInput = { target: number; - visible: boolean; time: number; delta: number; }; /** * Um quadro: rolagem amortecida, uniforms, câmera e cursor. Diz se foi o segundo quadro (a página pode tirar o fundo - * estático) e se algo ainda muda (então o próximo quadro é pedido de imediato). + * estático) e se a entrada ou a rolagem ainda assenta (então o próximo quadro é pedido de imediato; o do cursor, não: + * vem da batida). */ function stepField( world: World, state: FieldState, helpers: Helpers, layout: Layout, - { target, visible, time, delta }: StepInput, + { target, time, delta }: StepInput, ) { const dt = Math.min(delta, 1 / 20); const u = world.material.uniforms; @@ -769,11 +767,11 @@ function stepField( }; const tick: Tick = { p, face, time, dt, cursorActive }; updateRig(rig, tick); - const amount = updatePointer(rig, tick); + updatePointer(rig, tick); state.frames += 1; return { ready: state.frames === 2, - again: needsFrame(u, state, target, amount, cursorActive, visible), + again: isSettling(u, state, target), }; } @@ -866,8 +864,9 @@ function disposeParts({ renderer, world }: Parts) { /** * A cena sem React, a partir das peças já criadas: liga o tamanho, o mouse e a perda do contexto, e desenha sob * demanda. `invalidate` pede um quadro no próximo requestAnimationFrame (vários pedidos viram um); cada quadro - * atualiza os uniforms, a câmera e o cursor e pede o seguinte só se algo ainda muda. O primeiro quadro sai no - * requestAnimationFrame que `resize` pede, numa tarefa própria. + * atualiza os uniforms, a câmera e o cursor e pede o seguinte só se a entrada ou a rolagem ainda assenta (o resto vem + * da batida de 30 quadros por segundo). O primeiro quadro sai no requestAnimationFrame que `resize` pede, numa + * tarefa própria. */ function startField( host: HTMLElement, @@ -877,7 +876,6 @@ function startField( const { renderer, world, state, helpers } = parts; const started = performance.now(); let layout = frameLayout(1, 1, world.camera.fov, renderer.getPixelRatio()); - let visible = false; let alive = true; let raf = 0; let last = 0; @@ -890,32 +888,25 @@ function startField( if (alive) onError(); }; + /** Um quadro: avança a cena, avisa a página, pede o seguinte se ainda assenta e desenha. */ + const draw = (now: number) => { + const delta = last ? (now - last) / 1000 : 1 / 60; + last = now; + const step = stepField(world, state, helpers, layout, { + target: progressRef.current ?? 0, + time: (now - started) / 1000, + delta, + }); + if (step.ready) onReady(); + if (step.again) invalidate(); + renderer.render(world.scene, world.camera); + }; + function render(now: number) { raf = 0; if (!alive) return; - const target = progressRef.current ?? 0; - // só o cursor pede quadro (a entrada e a rolagem seguem a cada quadro): pula este se faz menos de ~32 ms do - // último e pede o próximo, em vez de desenhar a cada quadro da tela - if ( - last && - now - last < CURSOR_FRAME_MS && - !isSettling(world.material.uniforms, state, target) - ) { - invalidate(); - return; - } - const delta = last ? (now - last) / 1000 : 1 / 60; - last = now; try { - const step = stepField(world, state, helpers, layout, { - target, - visible, - time: (now - started) / 1000, - delta, - }); - if (step.ready) onReady(); - if (step.again) invalidate(); - renderer.render(world.scene, world.camera); + draw(now); } catch { fail(); } @@ -941,7 +932,6 @@ function startField( return { renderAt: render, setVisible(next) { - visible = next; if (next) stopPulse ??= startFramePulse(invalidate); else { stopPulse?.(); @@ -958,7 +948,7 @@ function startField( }; } -/** A cena sem React, de uma vez só: cria o renderizador dentro de `host` e começa a desenhar. Lança se o aparelho não cria um contexto WebGL. */ +/** A cena sem React, de uma vez só: cria o renderizador dentro de `host` e começa a desenhar. Lança se o aparelho não cria um contexto WebGL ou se ele desenha por software. */ export function createField( host: HTMLElement, callbacks: FieldCallbacks, @@ -974,7 +964,7 @@ const nextTask = () => new Promise((resolve) => setTimeout(resolve, 0)); * era uma só tarefa de 90 a 250 ms): o renderizador (o contexto WebGL); a nuvem de pontos e o estado; a compilação do * shader (`compileAsync`, que o navegador faz fora da thread principal); e, por fim, o tamanho e o primeiro quadro * (que sai no requestAnimationFrame seguinte). Devolve `null` se `isAlive` virou falso no caminho; lança se o - * aparelho não cria um contexto WebGL ou se o shader não compila. + * aparelho não cria um contexto WebGL, se ele desenha por software ou se o shader não compila. */ export async function mountField( host: HTMLElement, diff --git a/app/components/showcase/PinnedReel.tsx b/app/components/showcase/PinnedReel.tsx index 1ac952d..a738403 100644 --- a/app/components/showcase/PinnedReel.tsx +++ b/app/components/showcase/PinnedReel.tsx @@ -10,11 +10,13 @@ import { useRef, useState, } from "react"; +import { isWebKit } from "@/lib/device"; import { - chapterFraction, - chapterIndexAt, + chapterIndexAtScroll, + chapterScrollFraction, getScrubSource, getShowcaseAssets, + videoFractionAtScroll, } from "@/lib/project-showcase"; import { BrowserFrame, displayUrl } from "./BrowserFrame"; import { PhoneFrame } from "./PhoneFrame"; @@ -23,8 +25,9 @@ import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider"; import { CAPTION_OUT_MS, easeToward, + localAtProgress, locateInReel, - REEL_HOLD, + reelHolds, reelOffset, videoProgress, } from "./showcase-math"; @@ -69,9 +72,13 @@ function seekVideo(video: HTMLVideoElement | null, fraction: number) { video.currentTime = target; } -/** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */ +/** + * O WebKit (Safari, iPhone, iPad) só desenha o primeiro quadro de um vídeo que já tocou uma vez. Só ele toca e pausa: + * nos outros navegadores tocar faz o arquivo inteiro ser baixado (os 4 vídeos eram 3,2 MB por visita, contra 63 KB + * quando só as buscas da rolagem pedem os trechos que o visitante vê). + */ function prime(video: HTMLVideoElement) { - if (video.dataset.primed) return; + if (video.dataset.primed || !isWebKit()) return; video.dataset.primed = "true"; video .play() @@ -118,11 +125,15 @@ const tickState = (tick: number, current: number) => { return tick === current ? "now" : "done"; }; -/** Progresso (0 a 1) de cada vídeo quando o projeto `index` está na fração `local` do trecho dele. */ +/** + * Progresso da rolagem (0 a 1, sem as pausas) de cada projeto quando o projeto `index` está na fração `local` do trecho + * dele. É da rolagem, não do vídeo: os capítulos têm rolagem igual (`videoFractionAtScroll` leva ao ponto do vídeo). + */ function targetsAt(count: number, index: number, local: number) { + const { head, tail } = reelHolds(index, count); return Array.from({ length: count }, (_, i) => { if (i < index) return 1; - return i === index ? videoProgress(local) : 0; + return i === index ? videoProgress(local, head, tail) : 0; }); } @@ -233,12 +244,15 @@ export default function PinnedReel({ items }: { items: ShowcaseItem[] }) { const next = easeToward(state.smooth[index] ?? 0, target, seconds); state.smooth[index] = next; if (Math.abs(next - target) > 0.0004) moving = true; - seekVideo(desktopVideos.current[index], next); + seekVideo( + desktopVideos.current[index], + videoFractionAtScroll(items[index].config, next), + ); bars.current[index]?.style.setProperty("--p", next.toFixed(4)); }); const current = items[state.active]; if (current) { - const index = chapterIndexAt( + const index = chapterIndexAtScroll( current.config, state.smooth[state.active] ?? 0, ); @@ -253,20 +267,26 @@ export default function PinnedReel({ items }: { items: ShowcaseItem[] }) { ); // um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está - const syncVideo = useCallback((index: number) => { - const state = live.current; - seekVideo( - desktopVideos.current[index], - state.smooth[index] ?? state.target[index] ?? 0, - ); - }, []); + const syncVideo = useCallback( + (index: number) => { + const state = live.current; + seekVideo( + desktopVideos.current[index], + videoFractionAtScroll( + items[index].config, + state.smooth[index] ?? state.target[index] ?? 0, + ), + ); + }, + [items], + ); /** Troca o projeto em cena: o capítulo vem do ponto em que o vídeo dele já está. */ const changeActive = useCallback( (index: number) => { const state = live.current; state.active = index; - state.chapter = chapterIndexAt( + state.chapter = chapterIndexAtScroll( items[index].config, state.smooth[index] ?? 0, ); @@ -332,12 +352,9 @@ export default function PinnedReel({ items }: { items: ShowcaseItem[] }) { const goTo = (index: number) => scrollToReel(index, 0); const goToChapter = (index: number, chapterIndex: number) => { - const fraction = chapterFraction( - items[index].config, - chapterIndex, - "desktop", - ); - scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2)); + const { head, tail } = reelHolds(index, items.length); + const progress = chapterScrollFraction(items[index].config, chapterIndex); + scrollToReel(index, localAtProgress(progress, head, tail)); }; const current = items[active]; diff --git a/app/components/showcase/ProjectActions.paths.test.tsx b/app/components/showcase/ProjectActions.paths.test.tsx index c19565f..2ae9853 100644 --- a/app/components/showcase/ProjectActions.paths.test.tsx +++ b/app/components/showcase/ProjectActions.paths.test.tsx @@ -15,12 +15,17 @@ jest.mock("next/link", () => ({ __esModule: true, default: ({ children, - prefetch: _prefetch, + prefetch, ...props }: { children?: React.ReactNode; prefetch?: unknown } & Record< string, unknown - >) => {children}, + >) => ( + // o que o Link recebeu em `prefetch` aparece no atributo: false = nunca pré-carrega, null = o padrão do Next + + {children} + + ), })); const base = buildShowcaseItems(getFeaturedProjects(null)).find( @@ -73,6 +78,30 @@ describe("ProjectActions sem grouped (a lista)", () => { expect(container.querySelector("[tabindex]")).toBeNull(); }); + it.each([ + ["without grouped", false], + ["grouped (the pinned reel)", true], + ])( + "path: 'Ver case' %s -> prefetches only when the visitor arrives at it (hover, focus or touch)", + (_name, grouped) => { + render( + , + ); + const anchor = link(/Ver case/); + + // um por projeto, e na lista todos à vista: nenhum pedido de pré-carregamento na abertura da página + expect(anchor).toHaveAttribute("data-prefetch", "false"); + + fireEvent.mouseEnter(anchor); + + expect(anchor).toHaveAttribute("data-prefetch", "null"); + }, + ); + it("path: no onExpand (before the page mounts) and no site -> only 'Ver case'", () => { render(); diff --git a/app/components/showcase/ProjectActions.tsx b/app/components/showcase/ProjectActions.tsx index 49322cf..c571689 100644 --- a/app/components/showcase/ProjectActions.tsx +++ b/app/components/showcase/ProjectActions.tsx @@ -1,6 +1,6 @@ "use client"; -import Link from "next/link"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import type { ShowcaseItem } from "./ShowcaseProvider"; import { useRovingTabindex } from "./useRovingTabindex"; @@ -51,14 +51,14 @@ export function ProjectActions({ } : {})} > - Ver case de {project.title} - + {onExpand && ( diff --git a/app/components/showcase/ProjectReel.paths.test.tsx b/app/components/showcase/ProjectReel.paths.test.tsx index c70a57e..983226e 100644 --- a/app/components/showcase/ProjectReel.paths.test.tsx +++ b/app/components/showcase/ProjectReel.paths.test.tsx @@ -215,6 +215,7 @@ afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); window.matchMedia = originalMatchMedia; + Reflect.deleteProperty(navigator, "userAgent"); Object.defineProperty(navigator, "connection", { configurable: true, value: undefined, @@ -535,22 +536,61 @@ describe("ProjectReel: vídeos", () => { expect(videos()[0].currentTime).toBeGreaterThan(0); }); - it("path: first frame decoded -> primed once (play then pause) even if loadeddata fires twice", async () => { + it("path: Chrome (anything but WebKit) -> the video never plays: playing downloads the whole file, scrubbing only asks for what is seen", async () => { renderReel(); const video = videos()[0]; - fireEvent(video, new Event("loadeddata")); fireEvent(video, new Event("loadeddata")); await act(async () => { await Promise.resolve(); }); - expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1); - expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1); - expect(video.dataset.primed).toBe("true"); + expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); + expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled(); + expect(video.dataset.primed).toBeUndefined(); }); + it.each([ + [ + "iPhone (Safari)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + ], + [ + "iPhone (Chrome, also WebKit)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0 Mobile/15E148 Safari/604.1", + ], + [ + "Safari on the Mac", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + ], + ])( + "path: %s (WebKit) -> primed once (play then pause) even if loadeddata fires twice", + async (_name, userAgent) => { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: userAgent, + }); + renderReel(); + const video = videos()[0]; + + fireEvent(video, new Event("loadeddata")); + fireEvent(video, new Event("loadeddata")); + await act(async () => { + await Promise.resolve(); + }); + + expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1); + expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1); + expect(video.dataset.primed).toBe("true"); + }, + ); + it("path: priming is refused by the browser -> swallowed, no pause", async () => { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + }); jest .mocked(HTMLMediaElement.prototype.play) .mockRejectedValue(new DOMException("blocked", "NotAllowedError")); @@ -621,9 +661,9 @@ describe("ProjectReel: estado das camadas, traços de capítulo e legenda", () = scrollTo(0); - expect(states(".ds-reel-ticks li")).toEqual(["now", "todo", "todo"]); + expect(states(".ds-reel-ticks li")).toEqual(["now", "todo"]); expect(caption().querySelector(".ds-reel-chapter-count")).toHaveTextContent( - "01/03", + "01/02", ); }); diff --git a/app/components/showcase/ProjectReel.test.tsx b/app/components/showcase/ProjectReel.test.tsx index 25282c1..1bcf1fc 100644 --- a/app/components/showcase/ProjectReel.test.tsx +++ b/app/components/showcase/ProjectReel.test.tsx @@ -1,10 +1,11 @@ -import fs from "node:fs"; -import path from "node:path"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { renderToString } from "react-dom/server"; import { MOBILE_QUERY } from "@/lib/device"; import { getFeaturedProjects } from "@/lib/portfolio-data"; -import { getShowcaseConfig } from "@/lib/project-showcase"; +import { + getShowcaseConfig, + videoFractionAtScroll, +} from "@/lib/project-showcase"; import { ProjectReel } from "./ProjectReel"; import { ShowcaseProvider } from "./ShowcaseProvider"; import { CAPTION_OUT_MS, reelOffset, videoProgress } from "./showcase-math"; @@ -290,11 +291,18 @@ describe("ProjectReel", () => { const layer = activeLayer(); expect(layer.querySelector(".ds-reel-title")).toBeNull(); expect(caption()).toHaveTextContent("Lavação 047"); - const p = videoProgress(0.5); + // a rolagem do projeto é repartida por igual entre os capítulos: o meio dela é onde o 2º capítulo começa no vídeo + const lavacao = getShowcaseConfig("lavacao-047"); + if (!lavacao) throw new Error("projeto fora da vitrine"); + const p = videoFractionAtScroll(lavacao, videoProgress(0.5)); const desktop = layer.querySelector( ".ds-reel-screen video", ) as HTMLVideoElement; expect(desktop.currentTime).toBeCloseTo(p * 30, 1); + expect(p).toBeCloseTo( + lavacao.chapters[1].desktop / lavacao.durations.desktop, + 9, + ); // o celular da moldura só tem o pôster: nenhum vídeo dentro dele expect(layer.querySelector(".ds-phone")).toBeInTheDocument(); expect(layer.querySelector(".ds-phone video")).toBeNull(); @@ -312,7 +320,7 @@ describe("ProjectReel", () => { expect(lavacao).toBeDefined(); expect( caption().querySelector(".ds-reel-chapter-count")?.textContent, - ).toMatch(/^0\d\/03$/); + ).toMatch(/^0\d\/02$/); const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? []; expect(labels).toContain( caption().querySelector(".ds-reel-chapter strong")?.textContent, @@ -512,16 +520,16 @@ describe("ProjectReel", () => { expect(list.querySelector("h2, h3")?.tagName).toBe("H2"); }); - it("limits each project to three chapters, in the index and in the caption", () => { + it("limits each project to two chapters, in the index and in the caption", () => { renderReel(); for (const layer of document.querySelectorAll(".ds-reel-layer")) expect( layer.querySelectorAll(".ds-reel-ticks li").length, - ).toBeLessThanOrEqual(3); + ).toBeLessThanOrEqual(2); const chapters = screen.getByRole("list", { name: "Trechos de SEV Moda Íntima", }); - expect(within(chapters).getAllByRole("button")).toHaveLength(3); + expect(within(chapters).getAllByRole("button")).toHaveLength(2); }); it("shows the real domain in the address bar, or just the project name, never github.com", () => { @@ -586,7 +594,9 @@ describe("ProjectReel", () => { it("scrolls to a chapter inside the project when its link is pressed", () => { renderReel(); const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" }); - fireEvent.click(within(nav).getByRole("button", { name: /As favoritas/ })); + fireEvent.click( + within(nav).getByRole("button", { name: /Coleção completa/ }), + ); act(() => { jest.advanceTimersByTime(1200); }); @@ -595,6 +605,11 @@ describe("ProjectReel", () => { const last = calls[calls.length - 1][0] as { top: number }; expect(last.top).toBeGreaterThan(reelOffset(weights, 0, 0) * SCROLLABLE); expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE); + // o 2º capítulo começa na metade da rolagem do projeto (que vai de REEL_HOLD a 1 - REEL_HOLD do trecho dele) + expect(last.top).toBeCloseTo( + reelOffset(weights, 0, 0.5) * SCROLLABLE + 2, + 0, + ); }); }); @@ -729,10 +744,10 @@ describe("ProjectReel: a troca de projeto não duplica nada na tela", () => { expect(titles()).toEqual(["SEV Moda Íntima"]); expect( caption().querySelector(".ds-reel-chapter-count")?.textContent, - ).not.toBe("01/03"); + ).not.toBe("01/02"); }); - it("never moves the caption text: no inline transform, and the CSS only fades it", () => { + it("never moves the caption text: no inline transform, while it leaves and when it comes back with the new content", () => { renderReel(); moveTo(1, 60); @@ -740,17 +755,7 @@ describe("ProjectReel: a troca de projeto não duplica nada na tela", () => { for (const element of caption().querySelectorAll("*")) expect((element as HTMLElement).style.transform).toBe(""); - const css = ["reel-list.css", "pinned-reel.css"] - .map((file) => fs.readFileSync(path.join(__dirname, file), "utf8")) - .join("\n"); - const rule = /\.ds-reel-caption \{[\s\S]*?\n\}/.exec(css)?.[0] ?? ""; - expect(rule).toContain("opacity"); - expect(rule).not.toMatch(/transform|translate/); - const layer = /\.ds-reel-layer \{[\s\S]*?\n\}/.exec(css)?.[0] ?? ""; - expect(layer).toContain("opacity"); - expect(layer).not.toMatch(/transform|translate/); - - // comportamento: a legenda sai (atributo, sem estilo inline) e volta com o conteúdo novo sem nunca se deslocar + // a legenda sai (atributo, sem estilo inline) e volta com o conteúdo novo sem nunca se deslocar expect(caption()).toHaveAttribute("data-leaving"); act(() => { jest.advanceTimersByTime(CAPTION_OUT_MS + 40); @@ -777,8 +782,8 @@ describe("ProjectReel: teclado do índice (roving tabindex)", () => { it("makes the whole index ONE tab stop (the current project), however many projects and chapters it lists", () => { renderReel(); const buttons = within(index()).getAllByRole("button"); - // 5 projetos e os 3 trechos do projeto em cena: 8 botões, uma parada - expect(buttons).toHaveLength(5 + 3); + // 5 projetos e os 2 trechos do projeto em cena: 7 botões, uma parada + expect(buttons).toHaveLength(5 + 2); expect(stops()).toHaveLength(1); expect(stops()[0]).toHaveAttribute("aria-current", "true"); expect(stops()[0]).toHaveAccessibleName("SEV Moda Íntima"); diff --git a/app/components/showcase/lazy-loading.test.tsx b/app/components/showcase/lazy-loading.test.tsx index df024e5..d7a31db 100644 --- a/app/components/showcase/lazy-loading.test.tsx +++ b/app/components/showcase/lazy-loading.test.tsx @@ -124,36 +124,4 @@ describe("carregamento sob demanda", () => { expect(mockLoads.pinned).toBe(1); expect(container.querySelector(".ds-reel-placeholder")).toBeNull(); }); - - it("4. the page code never imports the heavy modules by name: only the *Lazy files do, with import()", () => { - const fs = require("node:fs") as typeof import("node:fs"); - const path = require("node:path") as typeof import("node:path"); - const read = (file: string) => - fs.readFileSync(path.join(__dirname, file), "utf8"); - - expect(read("ShowcaseProvider.tsx")).not.toMatch( - /from "\.\/ProjectLightbox"/, - ); - expect(read("ProjectReel.tsx")).not.toMatch(/from "\.\/PinnedReel"/); - expect(read("ProjectLightboxLazy.tsx")).toMatch( - /import\("\.\/ProjectLightbox"\)/, - ); - expect(read("PinnedReelLazy.tsx")).toMatch(/import\("\.\/PinnedReel"\)/); - // e o CSS de cada um viaja com o componente dele, não com a página - expect(read("PinnedReel.tsx")).toMatch(/import "\.\/pinned-reel\.css"/); - expect(read("ProjectLightbox.tsx")).toMatch(/import "\.\/lightbox\.css"/); - // só as linhas de import contam: o comentário do layout pode citar os arquivos pelo nome - expect( - fs.readFileSync(path.join(__dirname, "../../layout.tsx"), "utf8"), - ).not.toMatch( - /^\s*import\s+["'][^"']*(pinned-reel|lightbox|reel-list)\.css["']/m, - ); - // a lista de projetos e o casco da vitrine (reel-list.css) vêm dentro do global: a home não carrega uma terceira - // folha bloqueante, e nenhum componente a importa - expect(read("ProjectReel.tsx")).not.toMatch(/reel-list\.css/); - expect(read("ProjectList.tsx")).not.toMatch(/reel-list\.css/); - expect( - fs.readFileSync(path.join(__dirname, "../../globals.css"), "utf8"), - ).toMatch(/@import "\.\/components\/showcase\/reel-list\.css"/); - }); }); diff --git a/app/components/showcase/lightbox.css b/app/components/showcase/lightbox.css index 78370e2..33b690c 100644 --- a/app/components/showcase/lightbox.css +++ b/app/components/showcase/lightbox.css @@ -337,6 +337,38 @@ html.ds-lb-open { } } +/* tamanhos do degradê da fileira de etapas (celular): sem rolagem a fileira não anima e os dois ficam em 0 */ +@property --ds-fade-start { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@property --ds-fade-end { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +/* o progresso é o da rolagem da própria fileira: sem rolar, só a borda direita esmaece; no meio, as duas; no fim, + só a esquerda */ +@keyframes ds-lb-edges { + 0% { + --ds-fade-start: 0px; + --ds-fade-end: 28px; + } + 10% { + --ds-fade-start: 28px; + } + 90% { + --ds-fade-end: 28px; + } + 100% { + --ds-fade-start: 28px; + --ds-fade-end: 0px; + } +} + @media (max-width: 639px) { .ds-lb { padding: 0; @@ -353,6 +385,19 @@ html.ds-lb-open { overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; + /* degradê na borda que ainda tem etapas escondidas: some sozinho quando a fileira cabe ou chega ao fim */ + mask-image: linear-gradient( + to right, + transparent, + #000 var(--ds-fade-start), + #000 calc(100% - var(--ds-fade-end)), + transparent + ); + + @supports (animation-timeline: scroll()) { + animation: ds-lb-edges linear both; + animation-timeline: scroll(self inline); + } } .ds-lb-chapters::-webkit-scrollbar { diff --git a/app/components/showcase/reel-list.css b/app/components/showcase/reel-list.css index 4c697e4..a7d92d2 100644 --- a/app/components/showcase/reel-list.css +++ b/app/components/showcase/reel-list.css @@ -223,6 +223,8 @@ .ds-reel-item { flex: 0 0 min(82%, 340px); scroll-snap-align: start; + /* um deslize rápido para em cada cartão, em vez de pular projetos */ + scroll-snap-stop: always; padding: var(--sp-5) var(--sp-4); background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.1); diff --git a/app/components/showcase/showcase-math.test.ts b/app/components/showcase/showcase-math.test.ts index cd0a9c4..c82c67e 100644 --- a/app/components/showcase/showcase-math.test.ts +++ b/app/components/showcase/showcase-math.test.ts @@ -1,6 +1,10 @@ import { easeToward, + localAtProgress, locateInReel, + REEL_HOLD, + REEL_LAST_TAIL, + reelHolds, reelStarts, videoProgress, } from "./showcase-math"; @@ -41,6 +45,34 @@ describe("videoProgress", () => { }); }); +describe("videoProgress with a shorter pause at the end", () => { + it("lets the video run until the tail starts", () => { + expect(videoProgress(0.9, 0.1, 0.03)).toBeCloseTo((0.9 - 0.1) / 0.87, 9); + expect(videoProgress(0.97, 0.1, 0.03)).toBe(1); + expect(videoProgress(1, 0.1, 0.03)).toBe(1); + expect(videoProgress(0.1, 0.1, 0.03)).toBe(0); + }); + + it("localAtProgress is its inverse inside the moving part", () => { + for (const progress of [0, 0.25, 0.5, 1]) { + const local = localAtProgress(progress, 0.1, 0.03); + expect(videoProgress(local, 0.1, 0.03)).toBeCloseTo(progress, 9); + } + expect(localAtProgress(0)).toBe(REEL_HOLD); + expect(localAtProgress(1)).toBeCloseTo(1 - REEL_HOLD, 9); + }); +}); + +describe("reelHolds", () => { + it("keeps the full pause everywhere but at the end of the last project, which has nothing to wait for", () => { + expect(reelHolds(0, 4)).toEqual({ head: REEL_HOLD, tail: REEL_HOLD }); + expect(reelHolds(2, 4)).toEqual({ head: REEL_HOLD, tail: REEL_HOLD }); + expect(reelHolds(3, 4)).toEqual({ head: REEL_HOLD, tail: REEL_LAST_TAIL }); + expect(REEL_LAST_TAIL).toBeLessThan(REEL_HOLD); + expect(reelHolds(0, 1).tail).toBe(REEL_LAST_TAIL); + }); +}); + describe("easeToward", () => { it("moves toward the target and never overshoots", () => { const first = easeToward(0, 1, 1 / 60); diff --git a/app/components/showcase/showcase-math.ts b/app/components/showcase/showcase-math.ts index 178c64f..13bc97d 100644 --- a/app/components/showcase/showcase-math.ts +++ b/app/components/showcase/showcase-math.ts @@ -38,6 +38,20 @@ export function reelStarts(weights: number[]): number[] { /** Fração do trecho de cada projeto em que o vídeo fica parado, no começo e no fim. */ export const REEL_HOLD = 0.1; +/** + * Pausa no fim do último projeto: depois dele não há troca a esperar, e o palco já se solta da página. Fica só o + * bastante para o último quadro chegar antes de o palco subir (era a mesma pausa de 10%, com a rolagem ainda presa). + */ +export const REEL_LAST_TAIL = 0.03; + +/** As pausas do projeto `index` de `count`: no começo e no fim do trecho dele. */ +export function reelHolds(index: number, count: number) { + return { + head: REEL_HOLD, + tail: index === count - 1 ? REEL_LAST_TAIL : REEL_HOLD, + }; +} + /** Avanço total (0 a 1) em que o projeto `index` está em `local` (0 a 1) do trecho dele. */ export function reelOffset(weights: number[], index: number, local: number) { const total = weights.reduce((sum, weight) => sum + weight, 0); @@ -49,11 +63,24 @@ export function reelOffset(weights: number[], index: number, local: number) { * O trecho de cada projeto tem uma pausa no começo e outra no fim: o vídeo só anda no meio. * Isso dá tempo de ler o título antes de o site começar a rolar e de descansar antes da troca. */ -export function videoProgress(local: number, hold = REEL_HOLD): number { - const span = 1 - hold * 2; +export function videoProgress( + local: number, + hold = REEL_HOLD, + tail = hold, +): number { + const span = 1 - hold - tail; return Math.min(1, Math.max(0, (local - hold) / span)); } +/** O contrário de `videoProgress`: onde, no trecho do projeto (0 a 1), o avanço `progress` do vídeo acontece. */ +export function localAtProgress( + progress: number, + hold = REEL_HOLD, + tail = hold, +) { + return hold + progress * (1 - hold - tail); +} + /** Aproxima `current` de `target` de forma suave, independente da taxa de quadros (`tau` em segundos). */ export function easeToward( current: number, diff --git a/app/globals.css b/app/globals.css index 31b2f7e..bf178de 100644 --- a/app/globals.css +++ b/app/globals.css @@ -781,6 +781,12 @@ ol.ds-list { } } +/* "Role para mergulhar" só vale com o mergulho: HeroField marca o hero com data-dive quando a cena 3D conduz a + rolagem. No hero estático (toque, aparelho fraco, WebGL por software, movimento reduzido) a dica some. */ +.hero:not([data-dive]) .hero-cue { + display: none; +} + .hero-cue-line { width: 56px; height: 1px; @@ -1019,6 +1025,8 @@ html:active-view-transition body { ::view-transition-group(site-header) { animation: none; + /* as imagens "page" (a que sai e a que entra) cobrem a faixa do cabeçalho: sem isto ele sumia durante a troca */ + z-index: 100; } /* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */ diff --git a/app/template.test.tsx b/app/template.test.tsx index 288de92..680a1f5 100644 --- a/app/template.test.tsx +++ b/app/template.test.tsx @@ -1,11 +1,9 @@ import { render, screen } from "@testing-library/react"; import { useEffect, useState } from "react"; +import { setNavigation } from "@/__mocks__/next/navigation"; import { resetFocusOnNavigation } from "./hooks/useFocusOnNavigation"; import Template from "./template"; -let mockPathname = "/cases"; -jest.mock("next/navigation", () => ({ usePathname: () => mockPathname })); - const mockMounts = jest.fn(); const mockProps: Array> = []; @@ -34,7 +32,7 @@ function Counter() { } beforeEach(() => { - mockPathname = "/cases"; + setNavigation({ pathname: "/cases" }); mockMounts.mockClear(); mockProps.length = 0; resetFocusOnNavigation(); @@ -70,7 +68,7 @@ describe("app/template", () => { expect(mockMounts).toHaveBeenCalledTimes(1); expect(screen.getByTestId("state")).toHaveTextContent("1"); - mockPathname = "/cases/sev-modaintima"; + setNavigation({ pathname: "/cases/sev-modaintima" }); rerender(