import { act, fireEvent, render, screen } from "@testing-library/react"; import { useState } from "react"; import { getFeaturedProjects } from "@/lib/portfolio-data"; import { buildShowcaseItems, ShowcaseProvider, useShowcaseCard, useShowcaseItems, useShowcaseOpen, } from "./ShowcaseProvider"; /** * Testes de caminhos-base de ShowcaseProvider (complemento de ShowcaseProvider.test.tsx): register() sem * IntersectionObserver, limiar de visibilidade, quem devolve o mouse, desmontagem do card em destaque, foco * que sai para dentro ou para fora do card, aba oculta, pausa do usuário e a janela ampliada. */ let reducedMotion = false; jest.mock("@/app/hooks/useReducedMotion", () => ({ useReducedMotion: () => reducedMotion, })); jest.mock("./ProjectLightbox", () => ({ ProjectLightbox: ({ index, items, onClose, onNavigate, }: { index: number; items: unknown[]; onClose: () => void; onNavigate: (index: number) => void; }) => (
aberto:{index} de {items.length}
), })); type FakeObserver = { callback: IntersectionObserverCallback; targets: Set; }; let observers: FakeObserver[] = []; const OriginalObserver = global.IntersectionObserver; class FakeIntersectionObserver { private readonly state: FakeObserver; constructor(callback: IntersectionObserverCallback) { this.state = { callback, targets: new Set() }; observers.push(this.state); } observe(element: Element) { this.state.targets.add(element); } unobserve(element: Element) { this.state.targets.delete(element); } disconnect() { this.state.targets.clear(); } takeRecords() { return []; } } function report(target: Element, ratio: number) { act(() => { for (const observer of observers) { observer.callback( [ { target, intersectionRatio: ratio, isIntersecting: ratio > 0, } as IntersectionObserverEntry, ], observer as unknown as IntersectionObserver, ); } }); } function place(element: Element, top: number) { element.getBoundingClientRect = () => ({ top, height: 300, bottom: top + 300, left: 0, right: 400, width: 400, x: 0, y: top, }) as DOMRect; } function flush() { act(() => { jest.advanceTimersByTime(40); }); } function Card({ id }: { id: string }) { const { frameRef, active, open, toggleAutoplay, userPaused } = useShowcaseCard(id); return (
); } const projects = getFeaturedProjects(null); function Cards({ ids }: { ids: string[] }) { return ( {ids.map((id) => ( ))} ); } function setup(ids = ["sev-modaintima", "lavacao-047"]) { const view = render(); const frame = (id: string) => screen.getByTestId(id); // tela de 768px de altura no jsdom: o meio fica em 384 if (ids.includes("sev-modaintima")) place(frame("sev-modaintima"), 100); // centro 250 if (ids.includes("lavacao-047")) place(frame("lavacao-047"), 230); // centro 380 return { view, frame }; } const isActive = (element: Element) => element.getAttribute("data-active") === "true"; beforeEach(() => { jest.useFakeTimers(); reducedMotion = false; observers = []; global.IntersectionObserver = FakeIntersectionObserver as unknown as typeof IntersectionObserver; }); afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); global.IntersectionObserver = OriginalObserver; Reflect.deleteProperty(document, "hidden"); }); describe("ShowcaseProvider: IntersectionObserver e visibilidade", () => { it("path: browser without IntersectionObserver -> cards register fine but none ever plays", () => { // @ts-expect-error simula um navegador sem IntersectionObserver global.IntersectionObserver = undefined; const { view, frame } = setup(); flush(); expect(isActive(frame("sev-modaintima"))).toBe(false); expect(isActive(frame("lavacao-047"))).toBe(false); expect(() => view.unmount()).not.toThrow(); }); it.each([ [0.32, false], [0.33, true], [1, true], ])("path: %f of the card on screen -> visible: %s", (ratio, expected) => { const { frame } = setup(["sev-modaintima"]); report(frame("sev-modaintima"), ratio); flush(); expect(isActive(frame("sev-modaintima"))).toBe(expected); }); it("path: a visibility report about an element that is not a card -> ignored", () => { const { frame } = setup(["sev-modaintima"]); const stranger = document.createElement("div"); report(frame("sev-modaintima"), 0.8); flush(); report(stranger, 1); flush(); expect(isActive(frame("sev-modaintima"))).toBe(true); }); it("path: the card leaves the screen -> it stops being the active one", () => { const { frame } = setup(["sev-modaintima"]); report(frame("sev-modaintima"), 0.8); flush(); expect(isActive(frame("sev-modaintima"))).toBe(true); report(frame("sev-modaintima"), 0); flush(); expect(isActive(frame("sev-modaintima"))).toBe(false); }); it("path: hidden tab -> nobody plays; back to visible -> the closest card plays again", () => { const { frame } = setup(); report(frame("sev-modaintima"), 0.8); report(frame("lavacao-047"), 0.8); flush(); expect(isActive(frame("lavacao-047"))).toBe(true); Object.defineProperty(document, "hidden", { configurable: true, value: true, }); act(() => { document.dispatchEvent(new Event("visibilitychange")); }); flush(); expect(isActive(frame("lavacao-047"))).toBe(false); expect(isActive(frame("sev-modaintima"))).toBe(false); Object.defineProperty(document, "hidden", { configurable: true, value: false, }); act(() => { document.dispatchEvent(new Event("visibilitychange")); }); flush(); expect(isActive(frame("lavacao-047"))).toBe(true); }); }); describe("ShowcaseProvider: mouse e foco", () => { function hoverSetup() { const result = setup(); report(result.frame("sev-modaintima"), 0.8); report(result.frame("lavacao-047"), 0.8); flush(); return result; } it("path: a pen counts as a pointer that hovers", () => { const { frame } = hoverSetup(); fireEvent.pointerEnter( frame("sev-modaintima").closest(".ds-card") as HTMLElement, { pointerType: "pen" }, ); flush(); expect(isActive(frame("sev-modaintima"))).toBe(true); }); it("path: another card's pointer leaves -> the hovered card keeps the video", () => { const { frame } = hoverSetup(); const sev = frame("sev-modaintima").closest(".ds-card") as HTMLElement; const lavacao = frame("lavacao-047").closest(".ds-card") as HTMLElement; fireEvent.pointerEnter(sev, { pointerType: "mouse" }); flush(); expect(isActive(frame("sev-modaintima"))).toBe(true); fireEvent.pointerLeave(lavacao, { pointerType: "mouse" }); flush(); expect(isActive(frame("sev-modaintima"))).toBe(true); }); it("path: the hovered card is not on screen -> the closest visible card plays instead", () => { const { frame } = hoverSetup(); report(frame("sev-modaintima"), 0); fireEvent.pointerEnter( frame("sev-modaintima").closest(".ds-card") as HTMLElement, { pointerType: "mouse" }, ); flush(); expect(isActive(frame("sev-modaintima"))).toBe(false); expect(isActive(frame("lavacao-047"))).toBe(true); }); it("path: focus moves to another element inside the same card -> hover is kept", () => { const { frame } = hoverSetup(); const card = frame("sev-modaintima").closest(".ds-card") as HTMLElement; const inside = screen.getByRole("button", { name: /ampliar sev-modaintima/, }); fireEvent.focusIn(inside); flush(); expect(isActive(frame("sev-modaintima"))).toBe(true); fireEvent.focusOut(card, { relatedTarget: inside }); flush(); expect(isActive(frame("sev-modaintima"))).toBe(true); }); it.each([ [ "to another card", () => screen.getByRole("button", { name: /ampliar lavacao-047/ }), ], ["to nothing", () => null], ])( "path: focus leaves the card %s -> hover is released", (_label, target) => { const { frame } = hoverSetup(); const card = frame("sev-modaintima").closest(".ds-card") as HTMLElement; fireEvent.focusIn( screen.getByRole("button", { name: /ampliar sev-modaintima/ }), ); flush(); expect(isActive(frame("sev-modaintima"))).toBe(true); fireEvent.focusOut(card, { relatedTarget: target() }); flush(); expect(isActive(frame("sev-modaintima"))).toBe(false); expect(isActive(frame("lavacao-047"))).toBe(true); }, ); it("path: the hovered card is removed from the page -> its hover is dropped and another card plays", () => { function Swap() { const [showFirst, setShowFirst] = useState(true); return ( {showFirst && } ); } render(); const sev = screen.getByTestId("sev-modaintima"); const lavacao = screen.getByTestId("lavacao-047"); place(sev, 100); place(lavacao, 230); report(sev, 0.8); report(lavacao, 0.8); fireEvent.pointerEnter(sev.closest(".ds-card") as HTMLElement, { pointerType: "mouse", }); flush(); expect(isActive(sev)).toBe(true); fireEvent.click(screen.getByRole("button", { name: "remover o primeiro" })); flush(); expect(screen.queryByTestId("sev-modaintima")).toBeNull(); expect(isActive(lavacao)).toBe(true); }); }); describe("ShowcaseProvider: pausa e janela ampliada", () => { it("path: the visitor pauses and resumes -> no card plays while paused", () => { const { frame } = setup(["lavacao-047"]); report(frame("lavacao-047"), 0.8); flush(); expect(isActive(frame("lavacao-047"))).toBe(true); fireEvent.click( screen.getByRole("button", { name: /pausa lavacao-047 false/ }), ); flush(); expect(isActive(frame("lavacao-047"))).toBe(false); fireEvent.click( screen.getByRole("button", { name: /pausa lavacao-047 true/ }), ); flush(); expect(isActive(frame("lavacao-047"))).toBe(true); }); it("path: opening a project that is not part of the showcase -> no window", () => { function OpenOutsider() { const { open } = useShowcaseCard("reddit-explorer"); return ( ); } render( , ); fireEvent.click( screen.getByRole("button", { name: /ampliar reddit-explorer/ }), ); flush(); expect(screen.queryByRole("dialog")).toBeNull(); }); it("path: navigating in the window to a valid index -> changes project; to an invalid one -> closes", () => { setup(); fireEvent.click( screen.getByRole("button", { name: /ampliar lavacao-047/ }), ); expect(screen.getByRole("dialog")).toHaveTextContent("aberto:1"); fireEvent.click(screen.getByRole("button", { name: "ir para o primeiro" })); expect(screen.getByRole("dialog")).toHaveTextContent("aberto:0"); fireEvent.click( screen.getByRole("button", { name: "ir para um que não existe" }), ); expect(screen.queryByRole("dialog")).toBeNull(); }); it("path: reduced motion -> pausing and resuming never makes a card play", () => { reducedMotion = true; const { frame } = setup(["lavacao-047"]); report(frame("lavacao-047"), 1); flush(); fireEvent.click( screen.getByRole("button", { name: /pausa lavacao-047 false/ }), ); flush(); fireEvent.click( screen.getByRole("button", { name: /pausa lavacao-047 true/ }), ); flush(); expect(isActive(frame("lavacao-047"))).toBe(false); }); }); describe("ShowcaseProvider: trabalho por quadro", () => { it("path: no card registered -> scroll and resize schedule no frame", () => { const raf = jest.spyOn(window, "requestAnimationFrame"); render(

sem cards

, ); flush(); raf.mockClear(); act(() => { window.dispatchEvent(new Event("scroll")); window.dispatchEvent(new Event("resize")); document.dispatchEvent(new Event("visibilitychange")); }); flush(); expect(raf).not.toHaveBeenCalled(); }); it("path: several scrolls inside one frame -> evaluated once", () => { const { frame } = setup(["lavacao-047"]); report(frame("lavacao-047"), 0.8); flush(); const raf = jest.spyOn(window, "requestAnimationFrame"); raf.mockClear(); act(() => { window.dispatchEvent(new Event("scroll")); window.dispatchEvent(new Event("scroll")); window.dispatchEvent(new Event("scroll")); }); expect(raf).toHaveBeenCalledTimes(1); }); }); describe("ShowcaseProvider: ganchos fora do provedor e cartão sem .ds-card", () => { it("path: hooks used outside the provider -> empty list, and open/toggle do nothing", () => { function Outsider() { const items = useShowcaseItems(); const open = useShowcaseOpen(); const { toggleAutoplay, open: openCard, active } = useShowcaseCard("x"); return ( ); } render(); const button = screen.getByRole("button", { name: "itens:0 ativo:false" }); expect(() => fireEvent.click(button)).not.toThrow(); expect(screen.queryByRole("dialog")).toBeNull(); }); it("path: a card whose frame has no .ds-card ancestor -> the frame itself listens for the pointer", () => { function Bare() { const { frameRef, active } = useShowcaseCard("lavacao-047"); return
; } function Other() { const { frameRef, active } = useShowcaseCard("sev-modaintima"); return
; } render( , ); const bare = screen.getByTestId("bare"); const other = screen.getByTestId("other"); place(other, 230); // mais perto do meio da tela place(bare, 100); report(other, 0.8); report(bare, 0.8); flush(); expect(isActive(other)).toBe(true); fireEvent.pointerEnter(bare, { pointerType: "mouse" }); flush(); expect(isActive(bare)).toBe(true); expect(isActive(other)).toBe(false); }); it("path: projects missing from the list -> only showcase projects that exist are built, in showcase order", () => { const only = projects.filter((project) => ["as-servicos", "sev-modaintima"].includes(project.id), ); const items = buildShowcaseItems(only); expect(items.map((item) => item.config.id)).toEqual([ "sev-modaintima", "as-servicos", ]); expect(buildShowcaseItems([])).toEqual([]); }); });