import { act, fireEvent, render, screen, waitFor, within, } 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; let reducedMotionListener: (() => void) | 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((event: string, callback: () => void) => { if (query === "(prefers-reduced-motion: reduce)" && event === "change") { reducedMotionListener = callback; } }), 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; reducedMotionListener = 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("initializes the 3D stage on a narrow touch viewport without hover", async () => { mockMatchMedia({ coarse: true, hover: false }); Object.defineProperty(window, "innerWidth", { configurable: true, value: 390, }); render(); expect(await screen.findByTestId("notebook-stage")).toBeInTheDocument(); expect(screen.queryByTestId("notebook-fallback")).not.toBeInTheDocument(); }); it("lets touch users select and open a project outside the scaled screen", async () => { mockMatchMedia({ coarse: true, hover: false }); render(); await screen.findByTestId("notebook-stage"); const controls = screen.getByRole("navigation", { name: "Navegar projetos do notebook", }); const button = within(controls).getByRole("button", { name: /Mostrar SC/i, }); fireEvent.click(button); expect(button).toHaveAttribute("aria-pressed", "true"); expect( within(controls).getByRole("link", { name: /Abrir projeto/i }), ).toHaveAttribute( "href", projects.find((project) => project.id === "sc-plus")?.demoUrl, ); }); it("returns to static content if reduced motion is enabled during the story", async () => { mockMatchMedia({ coarse: true, hover: false }); render(); await screen.findByTestId("notebook-stage"); mockMatchMedia({ reduced: true, coarse: true, hover: false }); act(() => reducedMotionListener?.()); expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument(); expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(); }); it("initializes the 3D stage on a desktop 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("keeps the mounted 3D stage when resizing from desktop to touch mobile", async () => { render(); const stage = await screen.findByTestId("notebook-stage"); Object.defineProperty(window, "innerWidth", { configurable: true, value: 360, }); mockMatchMedia({ coarse: true, hover: false }); act(() => window.dispatchEvent(new Event("resize"))); expect(screen.getByTestId("notebook-stage")).toBe(stage); expect(screen.queryByTestId("notebook-fallback")).not.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("respects reduced motion independently of width and pointer capabilities", () => { const matchMedia = (query: string) => ({ matches: query === "(hover: hover)", }) as MediaQueryList; expect(canUseNotebook3D({ matchMedia })).toBe(true); expect( canUseNotebook3D({ 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(); }); });