diff --git a/app/components/ProjectCover.tsx b/app/components/ProjectCover.tsx index 852e539..7d871dd 100644 --- a/app/components/ProjectCover.tsx +++ b/app/components/ProjectCover.tsx @@ -6,7 +6,6 @@ type CoverProject = { id: string; title: string; demoUrl?: string | null; - githubUrl?: string | null; }; /** Capturas de tela publicadas dos projetos que têm interface pública. */ diff --git a/app/components/ProjectVideo.paths.test.tsx b/app/components/ProjectVideo.paths.test.tsx new file mode 100644 index 0000000..1650cb9 --- /dev/null +++ b/app/components/ProjectVideo.paths.test.tsx @@ -0,0 +1,193 @@ +import { render, screen } from "@testing-library/react"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { ProjectVideo } from "./ProjectVideo"; + +/** + * Testes de caminhos-base de ProjectVideo (complemento de ProjectVideo.test.tsx): modo só pôster + * (movimento reduzido ou "economizar dados") com e sem pôster pedido, inativo x ativo, vídeo sem fontes, + * falha do play() e a combinação de ativo com modo só pôster. + */ + +let reducedMotion = false; +jest.mock("@/app/hooks/useReducedMotion", () => ({ + useReducedMotion: () => reducedMotion, +})); + +const video = getShowcaseAssets("nutriscan", "desktop"); + +function setSaveData(value: boolean | undefined) { + Object.defineProperty(navigator, "connection", { + configurable: true, + value: value === undefined ? undefined : { saveData: value }, + }); +} + +beforeEach(() => { + reducedMotion = false; + setSaveData(undefined); + jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); + jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); + setSaveData(undefined); +}); + +describe("ProjectVideo: modo só pôster", () => { + it.each([ + [ + "reduced motion", + () => { + reducedMotion = true; + }, + ], + ["the data saver", () => setSaveData(true)], + ])( + "path: %s and the poster is not wanted yet -> renders nothing at all", + (_l, arrange) => { + arrange(); + + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }, + ); + + it.each([ + [ + "reduced motion", + () => { + reducedMotion = true; + }, + ], + ["the data saver", () => setSaveData(true)], + ])( + "path: %s and the poster is wanted -> only the image, with the project name in its alt", + (_l, arrange) => { + arrange(); + + const { container } = render( + , + ); + + expect(container.querySelector("video")).toBeNull(); + const image = screen.getByRole("img", { + name: "Captura de tela de Demo", + }); + expect(image).toHaveAttribute("width", String(video.width)); + expect(image).toHaveAttribute("height", String(video.height)); + }, + ); + + it("path: data saver off and motion allowed -> the video element, never the image", () => { + setSaveData(false); + + const { container } = render( + , + ); + + expect(container.querySelector("video")).not.toBeNull(); + expect(screen.queryByRole("img")).toBeNull(); + }); + + it("path: active card but poster-only mode -> nothing is attached and nothing plays", () => { + reducedMotion = true; + + render(); + + expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); + expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled(); + }); +}); + +describe("ProjectVideo: vídeo", () => { + it("path: far card (poster not wanted) -> the video has no poster attribute", () => { + const { container } = render( + , + ); + + expect(container.querySelector("video")).not.toHaveAttribute("poster"); + }); + + it("path: inactive and never activated -> no source, no load, no play, no pause", () => { + render(); + + expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled(); + expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); + expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled(); + }); + + it("path: becomes active -> muted is forced on the element before play()", () => { + const { container, rerender } = render( + , + ); + const element = container.querySelector("video") as HTMLVideoElement; + element.muted = false; + let mutedAtPlay: boolean | undefined; + jest.mocked(HTMLMediaElement.prototype.play).mockImplementation(function ( + this: HTMLMediaElement, + ) { + mutedAtPlay = this.muted; + return Promise.resolve(); + }); + + rerender(); + + expect(mutedAtPlay).toBe(true); + }); + + it("path: play() is rejected by the browser -> swallowed, the video stays mounted", async () => { + jest + .mocked(HTMLMediaElement.prototype.play) + .mockRejectedValue(new DOMException("blocked", "NotAllowedError")); + + const { container } = render( + , + ); + await Promise.resolve(); + + expect(container.querySelector("video")).not.toBeNull(); + }); + + it("path: no sources in the assets -> becomes active without calling load()", () => { + const empty = { ...video, sources: [] }; + + const { container } = render( + , + ); + + expect(container.querySelectorAll("source")).toHaveLength(0); + expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled(); + expect(HTMLMediaElement.prototype.play).toHaveBeenCalled(); + }); + + it("path: active -> inactive -> active again -> plays again without reloading the sources", () => { + const { container, rerender } = render( + , + ); + const loadsAfterFirst = jest.mocked(HTMLMediaElement.prototype.load).mock + .calls.length; + const playsAfterFirst = jest.mocked(HTMLMediaElement.prototype.play).mock + .calls.length; + + rerender(); + rerender(); + + expect(jest.mocked(HTMLMediaElement.prototype.load).mock.calls.length).toBe( + loadsAfterFirst, + ); + expect(jest.mocked(HTMLMediaElement.prototype.play).mock.calls.length).toBe( + playsAfterFirst + 1, + ); + expect(container.querySelectorAll("source")).toHaveLength(2); + }); +}); diff --git a/app/components/ProjectVideo.test.tsx b/app/components/ProjectVideo.test.tsx index da1f834..5d90b31 100644 --- a/app/components/ProjectVideo.test.tsx +++ b/app/components/ProjectVideo.test.tsx @@ -18,6 +18,10 @@ beforeEach(() => { afterEach(() => { jest.restoreAllMocks(); + Object.defineProperty(navigator, "connection", { + configurable: true, + value: undefined, + }); }); describe("ProjectVideo", () => { @@ -31,7 +35,7 @@ describe("ProjectVideo", () => { expect(element).toHaveAttribute("autoplay"); expect(element).toHaveAttribute("loop"); expect(element).toHaveAttribute("playsinline"); - expect(element).toHaveAttribute("preload", "none"); + expect(element).toHaveAttribute("preload", "metadata"); expect(element).toHaveAttribute("poster", video.poster); expect(element?.muted).toBe(true); expect(element).toHaveAttribute("data-ready", "false"); @@ -70,6 +74,62 @@ describe("ProjectVideo", () => { expect(element).toHaveAttribute("data-ready", "true"); }); + it("does not even request the poster while the card is far from the screen", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector("video")).not.toHaveAttribute("poster"); + + rerender( + , + ); + expect(container.querySelector("video")).toHaveAttribute( + "poster", + video.poster, + ); + }); + + it("swaps to the sources of the new device when the version changes", () => { + const mobile = getShowcaseAssets("nutriscan", "mobile"); + const { container, rerender } = render( + , + ); + const loads = jest.mocked(HTMLMediaElement.prototype.load).mock.calls + .length; + + rerender(); + expect( + [...container.querySelectorAll("source")].map((source) => + source.getAttribute("src"), + ), + ).toEqual(mobile.sources.map((source) => source.src)); + expect( + jest.mocked(HTMLMediaElement.prototype.load).mock.calls.length, + ).toBeGreaterThan(loads); + }); + + it("shows only the poster, with no video element, when the data saver is on", () => { + Object.defineProperty(navigator, "connection", { + configurable: true, + value: { saveData: true }, + }); + const { container } = render( + , + ); + + expect(container.querySelector("video")).toBeNull(); + expect(container.querySelector("source")).toBeNull(); + expect( + screen.getByRole("img", { name: "Captura de tela de Demo" }), + ).toBeInTheDocument(); + expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); + }); + it("shows only the poster, with no video element, when the user prefers reduced motion", () => { reducedMotion = true; const { container } = render( diff --git a/app/components/ProjectVideo.tsx b/app/components/ProjectVideo.tsx index 2e11f24..e6ab5ef 100644 --- a/app/components/ProjectVideo.tsx +++ b/app/components/ProjectVideo.tsx @@ -4,36 +4,44 @@ import Image from "next/image"; import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import type { ShowcaseAssets } from "@/lib/project-showcase"; +import { useSaveData } from "./showcase/useShowcaseMedia"; /** - * Vídeo de rolagem na capa do card. Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que - * está sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o - * card fica ativo; fora de cena ele pausa. Com prefers-reduced-motion só o poster aparece. - * O poster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar. + * Vídeo de rolagem na capa do card. `video` já é a versão do aparelho (quem chama escolhe: celular abaixo de + * 768 px; no servidor, nenhuma). Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que está + * sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o card + * fica ativo (`preload="metadata"`, sem fontes até lá); fora de cena ele pausa. Com prefers-reduced-motion ou + * "economizar dados" só o pôster aparece. `showPoster` falso (seção longe da tela) não pede nem o pôster. + * O pôster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar. */ export function ProjectVideo({ video, title, active, + showPoster = true, }: { video: ShowcaseAssets; title: string; active: boolean; + showPoster?: boolean; }) { const reducedMotion = useReducedMotion(); + const saveData = useSaveData(); const ref = useRef(null); const [attached, setAttached] = useState(false); const [ready, setReady] = useState(false); - const playing = active && !reducedMotion; + const posterOnly = reducedMotion || saveData; + const playing = active && !posterOnly; + const firstSource = video.sources[0]?.src; useEffect(() => { if (playing) setAttached(true); }, [playing]); useEffect(() => { - // as fontes entram depois do primeiro render: o elemento precisa ler a lista nova - if (attached) ref.current?.load(); - }, [attached]); + // as fontes entram depois do primeiro render (ou mudam com o aparelho): o elemento precisa ler a lista nova + if (attached && firstSource) ref.current?.load(); + }, [attached, firstSource]); useEffect(() => { const element = ref.current; @@ -46,7 +54,8 @@ export function ProjectVideo({ } }, [attached, playing]); - if (reducedMotion) { + if (posterOnly) { + if (!showPoster) return null; return ( { @@ -11,3 +11,66 @@ describe("static cinematic effects", () => { expect(grain.container.querySelector("canvas")).not.toBeInTheDocument(); }); }); + +describe("film grain over the project screens", () => { + let screenTop = 5000; + + function addScreen(className: string) { + const element = document.createElement("div"); + element.className = className; + element.getBoundingClientRect = () => + ({ top: screenTop, bottom: screenTop + 600, height: 600 }) as DOMRect; + document.body.append(element); + return element; + } + + function scroll(to: number) { + screenTop = to; + act(() => { + window.dispatchEvent(new Event("scroll")); + jest.advanceTimersByTime(40); + }); + } + + beforeEach(() => { + jest.useFakeTimers(); + screenTop = 5000; + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 800, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + document.body.innerHTML = ""; + }); + + it("steps aside while a project screen is in view and comes back when it leaves", () => { + addScreen("ds-reel"); + const { container } = render(); + const grain = container.firstElementChild as HTMLElement; + expect(grain).not.toHaveAttribute("data-over-projects"); + + scroll(300); // a vitrine entrou na janela + expect(grain).toHaveAttribute("data-over-projects"); + + scroll(-900); // passou: a cena acabou 300 px acima do topo + expect(grain).not.toHaveAttribute("data-over-projects"); + }); + + it("also covers the browser and phone frames", () => { + addScreen("ds-bframe"); + const { container } = render(); + scroll(100); + expect(container.firstElementChild).toHaveAttribute("data-over-projects"); + }); + + it("stays on when there is no project screen on the page", () => { + const { container } = render(); + scroll(100); + expect(container.firstElementChild).not.toHaveAttribute( + "data-over-projects", + ); + }); +}); diff --git a/app/components/cinematic/FilmGrain.paths.test.tsx b/app/components/cinematic/FilmGrain.paths.test.tsx new file mode 100644 index 0000000..2d5e1eb --- /dev/null +++ b/app/components/cinematic/FilmGrain.paths.test.tsx @@ -0,0 +1,139 @@ +import { act, render } from "@testing-library/react"; +import FilmGrain from "./FilmGrain"; + +/** + * Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): cada condição do retângulo + * de uma tela de projeto (altura, fim acima do topo da janela, início abaixo da janela), várias telas, quadros + * agrupados e a limpeza ao desmontar. + */ + +const INNER_HEIGHT = 800; + +function addScreen( + className: string, + rect: { top: number; bottom: number; height: number }, +) { + const element = document.createElement("div"); + element.className = className; + element.getBoundingClientRect = () => rect as DOMRect; + document.body.append(element); + return element; +} + +beforeEach(() => { + jest.useFakeTimers(); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: INNER_HEIGHT, + }); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +function renderGrain() { + const view = render(); + return { ...view, grain: view.container.firstElementChild as HTMLElement }; +} + +describe("FilmGrain: is a project screen in view?", () => { + it("path: a visible screen -> the grain steps aside", () => { + addScreen("ds-phone", { top: 100, bottom: 700, height: 600 }); + + const { grain } = renderGrain(); + + expect(grain).toHaveAttribute("data-over-projects"); + }); + + it.each([ + ["has no height (hidden by CSS)", { top: 100, bottom: 100, height: 0 }], + ["ended exactly at the top edge", { top: -600, bottom: 0, height: 600 }], + ["ended above the window", { top: -900, bottom: -300, height: 600 }], + [ + "starts exactly at the bottom edge", + { top: INNER_HEIGHT, bottom: 1400, height: 600 }, + ], + ["starts below the window", { top: 2000, bottom: 2600, height: 600 }], + ])("path: a screen that %s -> does not count", (_label, rect) => { + addScreen("ds-bframe", rect); + + const { grain } = renderGrain(); + + expect(grain).not.toHaveAttribute("data-over-projects"); + }); + + it("path: just inside the window by one pixel at either edge -> counts", () => { + addScreen("ds-frame", { top: -599, bottom: 1, height: 600 }); + const top = renderGrain(); + expect(top.grain).toHaveAttribute("data-over-projects"); + top.unmount(); + document.body.innerHTML = ""; + + addScreen("ds-frame", { top: INNER_HEIGHT - 1, bottom: 1400, height: 600 }); + const bottom = renderGrain(); + expect(bottom.grain).toHaveAttribute("data-over-projects"); + }); + + it("path: one screen off the window and another in view -> counts (any is enough)", () => { + addScreen("ds-reel", { top: 5000, bottom: 5600, height: 600 }); + addScreen("ds-phone", { top: 100, bottom: 700, height: 600 }); + + const { grain } = renderGrain(); + + expect(grain).toHaveAttribute("data-over-projects"); + }); + + it("path: no project screen on the page -> the grain stays", () => { + const { grain } = renderGrain(); + + expect(grain).not.toHaveAttribute("data-over-projects"); + }); +}); + +describe("FilmGrain: frames and cleanup", () => { + it("path: many scroll events inside one frame -> the page is measured once", () => { + const screenEl = addScreen("ds-reel", { + top: 5000, + bottom: 5600, + height: 600, + }); + const measure = jest.spyOn(screenEl, "getBoundingClientRect"); + renderGrain(); + measure.mockClear(); + + act(() => { + window.dispatchEvent(new Event("scroll")); + window.dispatchEvent(new Event("scroll")); + window.dispatchEvent(new Event("resize")); + jest.advanceTimersByTime(40); + }); + + expect(measure).toHaveBeenCalledTimes(1); + }); + + it("path: unmount -> listeners removed and no measurement afterwards", () => { + const screenEl = addScreen("ds-reel", { + top: 5000, + bottom: 5600, + height: 600, + }); + const measure = jest.spyOn(screenEl, "getBoundingClientRect"); + const { unmount } = renderGrain(); + act(() => { + window.dispatchEvent(new Event("scroll")); + }); + + unmount(); + measure.mockClear(); + act(() => { + window.dispatchEvent(new Event("scroll")); + window.dispatchEvent(new Event("resize")); + jest.advanceTimersByTime(100); + }); + + expect(measure).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/cinematic/FilmGrain.tsx b/app/components/cinematic/FilmGrain.tsx index b2e01b1..008c0f0 100644 --- a/app/components/cinematic/FilmGrain.tsx +++ b/app/components/cinematic/FilmGrain.tsx @@ -1,3 +1,43 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** Telas dos projetos (vitrine e capas): nenhum efeito pode ficar por cima delas. */ +const PROJECT_SCREENS = ".ds-reel, .ds-frame, .ds-bframe, .ds-phone"; + +/** Grão de filme sobre a página. Some enquanto qualquer tela de projeto está à vista (data-over-projects). */ export default function FilmGrain() { - return