import { act, render, screen, waitFor } from "@testing-library/react"; import { getFeaturedProjects } from "@/lib/portfolio-data"; import NotebookStory, { canUseNotebook3D } from "./NotebookStory"; const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
)); jest.mock("next/dynamic", () => () => { return function DynamicNotebookStage(props: { isVisible: boolean }) { return mockNotebookStage(props); }; }); jest.mock("gsap", () => ({ gsap: { registerPlugin: jest.fn(), timeline: jest.fn(() => ({ to: jest.fn().mockReturnThis() })), context: jest.fn((callback: () => void) => { callback(); return { revert: jest.fn() }; }), }, })); jest.mock("gsap/ScrollTrigger", () => ({ ScrollTrigger: { getAll: jest.fn(() => []) }, })); const projects = getFeaturedProjects(null); let intersectionCallback: IntersectionObserverCallback | undefined; const mockObserve = jest.fn(); const mockDisconnect = jest.fn(); function setDocumentHidden(hidden: boolean) { Object.defineProperty(document, "hidden", { configurable: true, value: hidden, }); } function setSectionIntersects(isIntersecting: boolean) { const entry = { isIntersecting } as IntersectionObserverEntry; act(() => intersectionCallback?.([entry], {} as IntersectionObserver)); } function mockMatchMedia({ reduced = false, coarse = false, hover = true, }: { reduced?: boolean; coarse?: boolean; hover?: boolean; } = {}) { const matcher = jest.isMockFunction(window.matchMedia) ? window.matchMedia : jest.spyOn(window, "matchMedia"); matcher.mockImplementation((query: string) => ({ matches: (query === "(prefers-reduced-motion: reduce)" && reduced) || (query === "(pointer: coarse)" && coarse) || (query === "(hover: hover)" && hover), media: query, onchange: null, addEventListener: jest.fn(), removeEventListener: jest.fn(), addListener: jest.fn(), removeListener: jest.fn(), dispatchEvent: jest.fn(), })); } describe("NotebookStory", () => { beforeEach(() => { mockMatchMedia(); Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280, }); setDocumentHidden(false); intersectionCallback = undefined; mockObserve.mockReset(); mockDisconnect.mockReset(); class MockIntersectionObserver { readonly root = null; readonly rootMargin = ""; readonly thresholds = []; constructor(callback: IntersectionObserverCallback) { intersectionCallback = callback; } disconnect = mockDisconnect; observe = mockObserve; takeRecords = () => []; unobserve = jest.fn(); } global.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver; }); afterEach(() => { jest.restoreAllMocks(); }); it("renders the static fallback when reduced motion is enabled", async () => { mockMatchMedia({ reduced: true }); render(); expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); await waitFor(() => expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(), ); }); it("does not initialize the 3D stage on coarse pointers", async () => { mockMatchMedia({ coarse: true }); render(); expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); await waitFor(() => expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(), ); }); it("initializes the 3D stage only on a desktop hover-capable viewport", async () => { render(); await waitFor(() => expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(), ); }); it("stays paused when the tab returns while the section is offscreen", async () => { render(); const stage = await screen.findByTestId("notebook-stage"); expect(stage).toHaveAttribute("data-is-visible", "false"); setSectionIntersects(true); await waitFor(() => expect(stage).toHaveAttribute("data-is-visible", "true"), ); setDocumentHidden(true); act(() => document.dispatchEvent(new Event("visibilitychange"))); await waitFor(() => expect(stage).toHaveAttribute("data-is-visible", "false"), ); setSectionIntersects(false); setDocumentHidden(false); act(() => document.dispatchEvent(new Event("visibilitychange"))); expect(stage).toHaveAttribute("data-is-visible", "false"); }); it("returns to the static fallback when the viewport loses desktop eligibility", async () => { render(); await waitFor(() => expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(), ); Object.defineProperty(window, "innerWidth", { configurable: true, value: 800, }); window.dispatchEvent(new Event("resize")); await waitFor(() => expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(), ); expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); }); it("keeps the static fallback when no mapped projects are available", async () => { mockMatchMedia(); render(); expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); expect( screen.getByLabelText("Projetos em produção no notebook"), ).toBeInTheDocument(); await waitFor(() => expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(), ); }); it("requires desktop hover input without reduced motion", () => { const matchMedia = (query: string) => ({ matches: query === "(hover: hover)", }) as MediaQueryList; expect(canUseNotebook3D({ innerWidth: 1280, matchMedia })).toBe(true); expect(canUseNotebook3D({ innerWidth: 1024, matchMedia })).toBe(false); expect( canUseNotebook3D({ innerWidth: 1280, matchMedia: (query) => ({ matches: query === "(hover: hover)" || query === "(prefers-reduced-motion: reduce)", }) as MediaQueryList, }), ).toBe(false); }); it("passes only mapped projects into the fallback browser", () => { mockMatchMedia({ reduced: true }); render(); expect(screen.getByRole("tab", { name: /NutriScan/i })).toBeInTheDocument(); expect( screen.queryByRole("tab", { name: /Twins Lab/i }), ).not.toBeInTheDocument(); }); });