266 lines
8.1 KiB
TypeScript
266 lines
8.1 KiB
TypeScript
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 }) => (
|
|
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
|
|
));
|
|
|
|
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(<NotebookStory projects={projects} />);
|
|
|
|
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(<NotebookStory projects={projects} />);
|
|
|
|
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(<NotebookStory projects={projects} />);
|
|
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(<NotebookStory projects={projects} />);
|
|
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(<NotebookStory projects={projects} />);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
|
);
|
|
});
|
|
|
|
it("stays paused when the tab returns while the section is offscreen", async () => {
|
|
render(<NotebookStory projects={projects} />);
|
|
|
|
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(<NotebookStory projects={projects} />);
|
|
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(<NotebookStory projects={[]} />);
|
|
|
|
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(<NotebookStory projects={projects} />);
|
|
|
|
expect(screen.getByRole("tab", { name: /NutriScan/i })).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("tab", { name: /Twins Lab/i }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|