import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import { carouselIndex, ProjectList } from "./ProjectList";
import { buildShowcaseItems, ShowcaseProvider } from "./ShowcaseProvider";
// o pacote da vitrine fixa e o da janela ampliada chegam sob demanda (React.lazy): aqui entram já, para o teste não esperar
jest.mock("./PinnedReelLazy", () => ({
PinnedReelLazy: jest.requireActual("./PinnedReel").default,
}));
jest.mock("./ProjectLightboxLazy", () => ({
// o ProjectLightbox deste teste é o dublê logo abaixo
ProjectLightboxLazy: jest.requireMock("./ProjectLightbox").ProjectLightbox,
}));
/**
* Testes de caminhos-base de ProjectList (e do ListItem dentro dela): sem aparelho (servidor), celular x desktop,
* longe x perto da janela (pôster), com e sem subtítulo, rótulo "pendente", o botão de ampliar e o pôster como
* elemento compartilhado da troca de página.
*/
jest.mock("./ProjectLightbox", () => ({
ProjectLightbox: ({ index }: { index: number }) => (
aberto:{index}
),
}));
// o dublê do põe as props num para o teste ler
jest.mock("react", () =>
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
);
type Callback = IntersectionObserverCallback;
/** Cada observador criado, com o elemento que observa. */
const observers: Array<{ callback: Callback; target?: Element }> = [];
const Original = global.IntersectionObserver;
const projects = getFeaturedProjects(null);
const items = buildShowcaseItems(projects);
beforeEach(() => {
observers.length = 0;
global.IntersectionObserver = class {
private readonly record: { callback: Callback; target?: Element };
constructor(callback: Callback) {
this.record = { callback };
observers.push(this.record);
}
observe(target: Element) {
this.record.target = target;
}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
});
afterEach(() => {
global.IntersectionObserver = Original;
});
function renderList(props: Partial
[0]> = {}) {
return render(
,
);
}
function bringNear(target: Element) {
act(() => {
for (const observer of observers)
if (observer.target === target)
observer.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
);
});
}
function bringAllNear(container: HTMLElement) {
for (const item of container.querySelectorAll("li.ds-reel-item"))
bringNear(item);
}
describe("ProjectList", () => {
it("path: device unknown (server / before mount) -> text and links only, no media and no expand button", () => {
const { container } = renderList({ device: null });
expect(container.querySelectorAll("li")).toHaveLength(items.length);
expect(container.querySelector("[data-device]")).toBeNull();
expect(container.querySelector(".ds-reel-item-media")).toBeNull();
expect(screen.queryByRole("button", { name: /Ampliar vídeo/ })).toBeNull();
expect(screen.getAllByRole("link", { name: /Ver case/ })).toHaveLength(
items.length,
);
});
it("path: desktop -> browser frame, poster only once the item is near the window", () => {
const { container } = renderList({ device: "desktop" });
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(items.length);
expect(container.querySelector(".ds-phone")).toBeNull();
expect(container.querySelector("img")).toBeNull();
bringAllNear(container);
const images = [...container.querySelectorAll("img")];
expect(images).toHaveLength(items.length);
expect(images[0]).toHaveAttribute(
"alt",
`Captura de tela de ${items[0].project.title}`,
);
expect(decodeURIComponent(images[0].getAttribute("src") ?? "")).toContain(
"desktop.webp",
);
expect(images[0]).toHaveAttribute("sizes", "50vw");
});
it("path: poster near the window -> shared element named after the case (case-), one name per item", () => {
const { container } = renderList({ device: "mobile" });
bringAllNear(container);
const names = [...container.querySelectorAll("img")].map((image) =>
image.parentElement?.getAttribute("data-vt-name"),
);
expect(names).toEqual(items.map(({ config }) => `case-${config.id}`));
const transition = container.querySelector("img")?.parentElement;
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
expect(transition).toHaveAttribute("data-vt-default", "none");
});
it("path: mobile -> phone frame with the phone poster and the phone sizes", () => {
const { container } = renderList({ device: "mobile" });
bringAllNear(container);
expect(container.querySelectorAll(".ds-phone")).toHaveLength(items.length);
expect(container.querySelector(".ds-bframe")).toBeNull();
const image = container.querySelector("img") as HTMLImageElement;
expect(decodeURIComponent(image.getAttribute("src") ?? "")).toContain(
"mobile.webp",
);
// o celular da moldura mede ~160 px no carrossel: o pôster de 390 px não viaja inteiro
expect(image).toHaveAttribute("sizes", "160px");
});
it("path: an item that is not near yet keeps its frame but no image", () => {
const { container } = renderList({ device: "desktop" });
bringNear(container.querySelector("li.ds-reel-item") as Element);
expect(container.querySelectorAll("img")).toHaveLength(1);
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(items.length);
});
it("path: project with a subtitle -> shown; without -> no subtitle paragraph", () => {
const withSubtitle = items.map((item, index) => ({
...item,
project: { ...item.project, subtitle: index === 0 ? "Loja virtual" : "" },
}));
const { container } = render(
,
);
const subs = container.querySelectorAll(".ds-reel-sub");
expect(subs).toHaveLength(1);
expect(subs[0]).toHaveTextContent("Loja virtual");
});
it("path: pending -> the section is marked so CSS can hide it; not pending -> unmarked", () => {
const pending = renderList({ pending: true, device: null });
expect(pending.container.querySelector("section")).toHaveAttribute(
"data-pending",
);
pending.unmount();
const settled = renderList({ pending: false });
expect(settled.container.querySelector("section")).not.toHaveAttribute(
"data-pending",
);
});
it("path: pressing 'Ampliar vídeo' on an item -> opens the enlarged window on that project", () => {
renderList({ device: "desktop" });
const second = screen
.getByRole("heading", { level: 3, name: items[1].project.title })
.closest("li") as HTMLElement;
fireEvent.click(
within(second).getByRole("button", { name: /Ampliar vídeo/ }),
);
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
"aberto:1",
);
});
});
describe("ProjectList: carrossel do celular", () => {
/** Dá ao carrossel as medidas que o jsdom não tem: largura de cada cartão, passo e rolagem. */
function lay(
list: HTMLElement,
{ scrollLeft = 0, card = 300, gap = 16 } = {},
) {
const cards = Array.from(list.children) as HTMLElement[];
Object.defineProperty(list, "clientWidth", {
configurable: true,
value: 360,
});
Object.defineProperty(list, "scrollWidth", {
configurable: true,
value: cards.length * (card + gap),
});
list.scrollLeft = scrollLeft;
cards.forEach((element, index) => {
element.getBoundingClientRect = () =>
({ left: index * (card + gap) - scrollLeft }) as DOMRect;
});
}
const list = (container: HTMLElement) =>
container.querySelector(".ds-reel-items") as HTMLElement;
const count = (container: HTMLElement) =>
container.querySelector(".ds-reel-count");
it("path: on the phone, shows '1/N' and one dot per project (decorative: the list already says how many there are)", () => {
const { container } = renderList({ device: "mobile" });
const counter = count(container) as HTMLElement;
expect(counter).toHaveAttribute("aria-hidden", "true");
expect(counter.textContent).toBe(`1/${items.length}`);
expect(counter.querySelectorAll("i")).toHaveLength(items.length);
expect(counter.querySelectorAll("i[data-on]")).toHaveLength(1);
});
it("path: no counter on the desktop grid, before mount, or with one project only", () => {
expect(count(renderList({ device: "desktop" }).container)).toBeNull();
expect(count(renderList({ device: null }).container)).toBeNull();
expect(
count(
renderList({ device: "mobile", items: items.slice(0, 1) }).container,
),
).toBeNull();
});
it("path: scrolling the strip moves the counter and the dot (one animation frame at a time)", () => {
jest.useFakeTimers();
try {
const { container } = renderList({ device: "mobile" });
const strip = list(container);
lay(strip, { scrollLeft: 316 * 2 });
act(() => {
strip.dispatchEvent(new Event("scroll"));
strip.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(40);
});
expect((count(container) as HTMLElement).textContent).toBe(
`3/${items.length}`,
);
const dots = [...(count(container) as HTMLElement).querySelectorAll("i")];
expect(dots.findIndex((dot) => dot.hasAttribute("data-on"))).toBe(2);
} finally {
jest.useRealTimers();
}
});
it("path: the strip scrolled to its end -> the last project, even if its start never reaches the edge", () => {
jest.useFakeTimers();
try {
const { container } = renderList({ device: "mobile" });
const strip = list(container);
lay(strip);
// fim da rolagem: scrollLeft + clientWidth = scrollWidth
strip.scrollLeft = strip.scrollWidth - strip.clientWidth;
act(() => {
strip.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(40);
});
expect((count(container) as HTMLElement).textContent).toBe(
`${items.length}/${items.length}`,
);
} finally {
jest.useRealTimers();
}
});
it("carouselIndex: 0 without horizontal scroll, never past the ends, and tolerant of a degenerate stride", () => {
const strip = document.createElement("ul");
for (let i = 0; i < 4; i++) strip.append(document.createElement("li"));
// sem rolagem para o lado (grade das telas maiores)
Object.defineProperty(strip, "scrollWidth", {
configurable: true,
value: 300,
});
Object.defineProperty(strip, "clientWidth", {
configurable: true,
value: 300,
});
expect(carouselIndex(strip)).toBe(0);
lay(strip, { scrollLeft: 316 });
expect(carouselIndex(strip)).toBe(1);
lay(strip, { scrollLeft: 316 * 10 });
expect(carouselIndex(strip)).toBe(3);
// cartões sobrepostos (passo zero): não divide por zero
for (const child of Array.from(strip.children))
(child as HTMLElement).getBoundingClientRect = () =>
({ left: 0 }) as DOMRect;
strip.scrollLeft = 10;
Object.defineProperty(strip, "scrollWidth", {
configurable: true,
value: 2000,
});
Object.defineProperty(strip, "clientWidth", {
configurable: true,
value: 360,
});
expect(carouselIndex(strip)).toBe(0);
// um cartão só
const single = document.createElement("ul");
single.append(document.createElement("li"));
expect(carouselIndex(single)).toBe(0);
});
it("path: 'Ampliar vídeo' keeps its accessible name (visually hidden on the phone, by CSS) and 'Ver case' is the first action", () => {
const { container } = renderList({ device: "mobile" });
const first = container.querySelector(".ds-reel-actions") as HTMLElement;
const names = [...first.querySelectorAll("a, button")].map((element) =>
element.textContent?.replace(/\s+/g, " ").trim(),
);
expect(names[0]).toMatch(/^Ver case/);
expect(
within(first).getByRole("button", { name: /^Ampliar vídeo de / }),
).toBeInTheDocument();
expect(first.querySelector(".ds-reel-expand-label")?.textContent).toBe(
"Ampliar vídeo",
);
});
});