911 lines
33 KiB
TypeScript
911 lines
33 KiB
TypeScript
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
|
import { renderToString } from "react-dom/server";
|
|
import { MOBILE_QUERY } from "@/lib/device";
|
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
|
import {
|
|
getShowcaseConfig,
|
|
videoFractionAtScroll,
|
|
} from "@/lib/project-showcase";
|
|
import { ProjectReel } from "./ProjectReel";
|
|
import { ShowcaseProvider } from "./ShowcaseProvider";
|
|
import { CAPTION_OUT_MS, reelOffset, videoProgress } from "./showcase-math";
|
|
import { PINNED_QUERY } from "./useShowcaseMedia";
|
|
|
|
// 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", () => ({
|
|
ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox,
|
|
}));
|
|
|
|
let reducedMotion = false;
|
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
|
useReducedMotion: () => reducedMotion,
|
|
}));
|
|
|
|
const PIN = 800;
|
|
const CONTAINER = 7000;
|
|
const SCROLLABLE = CONTAINER - PIN;
|
|
const HEADER = 60;
|
|
let reelTop = HEADER;
|
|
/** largura do celular da moldura: 0 simula uma moldura escondida por CSS */
|
|
let phoneWidth = 90;
|
|
|
|
const originalMatchMedia = window.matchMedia;
|
|
|
|
/** Tela de desktop por padrão (cabe a vitrine fixa); `phone` simula um celular. */
|
|
function mockViewport({ phone = false }: { phone?: boolean } = {}) {
|
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
|
matches:
|
|
(query === MOBILE_QUERY && phone) || (query === PINNED_QUERY && !phone),
|
|
media: query,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
}));
|
|
}
|
|
|
|
/** O arquivo do pôster que a imagem dentro de `element` pede (decodificado); "" sem imagem. */
|
|
const posterIn = (element: Element | null | undefined) =>
|
|
decodeURIComponent(element?.querySelector("img")?.getAttribute("src") ?? "");
|
|
|
|
const projects = getFeaturedProjects(null);
|
|
const weights = [
|
|
"sev-modaintima",
|
|
"lavacao-047",
|
|
"nutriscan",
|
|
"sc-plus",
|
|
"as-servicos",
|
|
"julia-beauty",
|
|
].map((id) => getShowcaseConfig(id)?.weight ?? 1);
|
|
|
|
const originalRect = Element.prototype.getBoundingClientRect;
|
|
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
|
|
HTMLElement.prototype,
|
|
"offsetHeight",
|
|
);
|
|
const originalOffsetWidth = Object.getOwnPropertyDescriptor(
|
|
HTMLElement.prototype,
|
|
"offsetWidth",
|
|
);
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
if (this.classList.contains("ds-reel")) return CONTAINER;
|
|
if (this.classList.contains("ds-reel-pin")) return PIN;
|
|
return 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
return this.classList.contains("ds-reel-phone") ? phoneWidth : 0;
|
|
},
|
|
});
|
|
// jsdom ainda não implementa o <dialog> modal
|
|
HTMLDialogElement.prototype.showModal = function showModal(
|
|
this: HTMLDialogElement,
|
|
) {
|
|
this.setAttribute("open", "");
|
|
};
|
|
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
|
|
this.removeAttribute("open");
|
|
this.dispatchEvent(new Event("close"));
|
|
};
|
|
Element.prototype.getBoundingClientRect = function rect(this: Element) {
|
|
if (this.classList.contains("ds-reel"))
|
|
return {
|
|
top: reelTop,
|
|
bottom: reelTop + CONTAINER,
|
|
height: CONTAINER,
|
|
left: 0,
|
|
right: 1000,
|
|
width: 1000,
|
|
x: 0,
|
|
y: reelTop,
|
|
} as DOMRect;
|
|
return originalRect.call(this);
|
|
};
|
|
window.scrollTo = jest.fn();
|
|
});
|
|
|
|
afterAll(() => {
|
|
Element.prototype.getBoundingClientRect = originalRect;
|
|
if (originalOffsetHeight)
|
|
Object.defineProperty(
|
|
HTMLElement.prototype,
|
|
"offsetHeight",
|
|
originalOffsetHeight,
|
|
);
|
|
if (originalOffsetWidth)
|
|
Object.defineProperty(
|
|
HTMLElement.prototype,
|
|
"offsetWidth",
|
|
originalOffsetWidth,
|
|
);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
reducedMotion = false;
|
|
reelTop = HEADER;
|
|
phoneWidth = 90;
|
|
mockViewport();
|
|
(window.scrollTo as jest.Mock).mockClear();
|
|
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
|
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
jest.restoreAllMocks();
|
|
window.matchMedia = originalMatchMedia;
|
|
Object.defineProperty(navigator, "connection", {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
});
|
|
|
|
function renderReel() {
|
|
const view = render(
|
|
<ShowcaseProvider projects={projects}>
|
|
<ProjectReel />
|
|
</ShowcaseProvider>,
|
|
);
|
|
act(() => {
|
|
jest.advanceTimersByTime(50);
|
|
});
|
|
return view;
|
|
}
|
|
|
|
/** jsdom não carrega mídia: cada <video> da vitrine ganha duração e prontidão para aceitar buscas. */
|
|
function readyVideos() {
|
|
for (const video of document.querySelectorAll("video")) {
|
|
Object.defineProperty(video, "readyState", {
|
|
configurable: true,
|
|
value: 4,
|
|
});
|
|
Object.defineProperty(video, "duration", { configurable: true, value: 30 });
|
|
let time = 0;
|
|
Object.defineProperty(video, "currentTime", {
|
|
configurable: true,
|
|
get: () => time,
|
|
set: (value: number) => {
|
|
time = value;
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
function scrollTo(progress: number) {
|
|
reelTop = HEADER - progress * SCROLLABLE;
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(1500);
|
|
});
|
|
// a legenda leva CAPTION_OUT_MS para trocar de projeto
|
|
act(() => {
|
|
jest.advanceTimersByTime(CAPTION_OUT_MS + 20);
|
|
});
|
|
}
|
|
|
|
const sources = () =>
|
|
[...document.querySelectorAll(".ds-reel-layer video")].map((video) =>
|
|
video.getAttribute("src"),
|
|
);
|
|
|
|
const activeLayer = () =>
|
|
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
|
|
|
/** A legenda do projeto em cena: um bloco só, fora das camadas. */
|
|
const caption = () => document.querySelector(".ds-reel-caption") as HTMLElement;
|
|
|
|
describe("ProjectReel", () => {
|
|
it("falls back to a plain project list, nothing pinned, for users who prefer reduced motion", () => {
|
|
reducedMotion = true;
|
|
renderReel();
|
|
expect(
|
|
screen.queryByRole("region", { name: /Vitrine animada/ }),
|
|
).toBeNull();
|
|
expect(document.querySelector(".ds-reel")).toBeNull();
|
|
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
|
expect(within(list).getAllByRole("heading", { level: 3 })).toHaveLength(6);
|
|
expect(document.querySelector("video")).toBeNull();
|
|
});
|
|
|
|
it("is a touch carousel of posters, with nothing pinned and no video, on a phone", () => {
|
|
mockViewport({ phone: true });
|
|
renderReel();
|
|
|
|
expect(document.querySelector(".ds-reel")).toBeNull();
|
|
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
|
expect(list).not.toHaveAttribute("data-pending");
|
|
expect(document.querySelector("video")).toBeNull();
|
|
// o item chega a ~1 tela da janela (o IntersectionObserver do jest.setup avisa): só então vem o pôster
|
|
act(() => {
|
|
jest.advanceTimersByTime(50);
|
|
});
|
|
const posters = [...list.querySelectorAll("img")].map((image) =>
|
|
decodeURIComponent(image.getAttribute("src") ?? ""),
|
|
);
|
|
expect(posters.length).toBeGreaterThan(0);
|
|
for (const poster of posters) expect(poster).toContain("mobile.webp");
|
|
expect(list.querySelectorAll(".ds-phone")).toHaveLength(6);
|
|
expect(list.querySelector(".ds-bframe")).toBeNull();
|
|
});
|
|
|
|
it("still opens the enlarged video from the list, without any card in the page", () => {
|
|
mockViewport({ phone: true });
|
|
renderReel();
|
|
const item = screen
|
|
.getByRole("heading", { level: 3, name: "SEV Moda Íntima" })
|
|
.closest("li") as HTMLElement;
|
|
fireEvent.click(
|
|
within(item).getByRole("button", { name: /Ampliar vídeo/ }),
|
|
);
|
|
expect(
|
|
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("lists every project by name, starting on the first with its chapters", () => {
|
|
renderReel();
|
|
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
|
for (const name of [
|
|
"SEV Moda Íntima",
|
|
"Lavação 047",
|
|
"NutriScan",
|
|
"SC Plus Aquicultura",
|
|
"AS Serviços",
|
|
"Julia Beauty",
|
|
])
|
|
expect(within(nav).getByRole("button", { name })).toBeInTheDocument();
|
|
expect(
|
|
within(nav).getByRole("button", { name: "SEV Moda Íntima" }),
|
|
).toHaveAttribute("aria-current", "true");
|
|
expect(
|
|
within(nav).getByRole("list", { name: "Trechos de SEV Moda Íntima" }),
|
|
).toBeInTheDocument();
|
|
expect(caption()).toHaveTextContent("SEV Moda Íntima");
|
|
});
|
|
|
|
it("hides the layers that are not on screen from assistive technology and from the keyboard", () => {
|
|
renderReel();
|
|
const layers = [...document.querySelectorAll(".ds-reel-layer")];
|
|
expect(layers).toHaveLength(6);
|
|
for (const layer of layers.slice(1)) {
|
|
expect(layer).toHaveAttribute("aria-hidden", "true");
|
|
expect(layer).toHaveAttribute("inert");
|
|
}
|
|
expect(layers[0]).not.toHaveAttribute("inert");
|
|
});
|
|
|
|
it("moves to the next project and syncs the laptop video to the scroll; the phone is just a poster", () => {
|
|
renderReel();
|
|
readyVideos();
|
|
|
|
// meio do trecho da Lavação (segundo projeto)
|
|
const t = reelOffset(weights, 1, 0.5);
|
|
scrollTo(t);
|
|
|
|
const layer = activeLayer();
|
|
expect(layer.querySelector(".ds-reel-title")).toBeNull();
|
|
expect(caption()).toHaveTextContent("Lavação 047");
|
|
// a rolagem do projeto é repartida por igual entre os capítulos: o meio dela é onde o 2º capítulo começa no vídeo
|
|
const lavacao = getShowcaseConfig("lavacao-047");
|
|
if (!lavacao) throw new Error("projeto fora da vitrine");
|
|
const p = videoFractionAtScroll(lavacao, videoProgress(0.5));
|
|
const desktop = layer.querySelector(
|
|
".ds-reel-screen video",
|
|
) as HTMLVideoElement;
|
|
expect(desktop.currentTime).toBeCloseTo(p * 30, 1);
|
|
expect(p).toBeCloseTo(
|
|
lavacao.chapters[1].desktop / lavacao.durations.desktop,
|
|
9,
|
|
);
|
|
// o celular da moldura só tem o pôster: nenhum vídeo dentro dele
|
|
expect(layer.querySelector(".ds-phone")).toBeInTheDocument();
|
|
expect(layer.querySelector(".ds-phone video")).toBeNull();
|
|
expect(posterIn(layer.querySelector(".ds-phone-screen"))).toContain(
|
|
"lavacao-047/mobile.webp",
|
|
);
|
|
});
|
|
|
|
it("names the chapter on screen and counts them", () => {
|
|
renderReel();
|
|
readyVideos();
|
|
scrollTo(reelOffset(weights, 1, 0.5)); // Lavação, a meio caminho
|
|
|
|
const lavacao = getShowcaseConfig("lavacao-047");
|
|
expect(lavacao).toBeDefined();
|
|
expect(
|
|
caption().querySelector(".ds-reel-chapter-count")?.textContent,
|
|
).toMatch(/^0\d\/02$/);
|
|
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
|
expect(labels).toContain(
|
|
caption().querySelector(".ds-reel-chapter strong")?.textContent,
|
|
);
|
|
});
|
|
|
|
it("holds the first and last frame at the ends of the pinned range", () => {
|
|
renderReel();
|
|
readyVideos();
|
|
|
|
scrollTo(0);
|
|
const first = document.querySelector(
|
|
'.ds-reel-layer[data-state="active"] .ds-reel-screen video',
|
|
) as HTMLVideoElement;
|
|
expect(first.currentTime).toBe(0);
|
|
|
|
scrollTo(1);
|
|
expect(caption()).toHaveTextContent("Julia Beauty");
|
|
const last = activeLayer().querySelector(
|
|
".ds-reel-screen video",
|
|
) as HTMLVideoElement;
|
|
expect(last.currentTime).toBeCloseTo(30, 1);
|
|
});
|
|
|
|
it("downloads nothing while the page is far from the reel", () => {
|
|
reelTop = 6000;
|
|
renderReel();
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(400);
|
|
});
|
|
expect(sources().every((src) => src === null)).toBe(true);
|
|
});
|
|
|
|
it("downloads video only for the project on screen (neighbours stay on the poster), and lets go of the rest", () => {
|
|
renderReel();
|
|
scrollTo(0);
|
|
act(() => {
|
|
jest.advanceTimersByTime(400);
|
|
});
|
|
let loaded = sources().filter(Boolean);
|
|
expect(
|
|
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
|
).toBe(true);
|
|
// o celular da moldura é só pôster: o vídeo de celular nunca é baixado na vitrine fixa
|
|
expect(loaded.some((src) => src?.includes("scrub-mobile"))).toBe(false);
|
|
// o vizinho (Lavação) e os demais ficam sem nenhum vídeo
|
|
expect(loaded.some((src) => src?.includes("lavacao-047"))).toBe(false);
|
|
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
|
|
expect(loaded.some((src) => src?.includes("as-servicos"))).toBe(false);
|
|
for (const video of document.querySelectorAll(".ds-reel-layer video"))
|
|
expect(video).toHaveAttribute("preload", "metadata");
|
|
|
|
// vai para o último projeto: o primeiro sai da janela e solta o arquivo
|
|
scrollTo(1);
|
|
act(() => {
|
|
jest.advanceTimersByTime(400);
|
|
});
|
|
loaded = sources().filter(Boolean);
|
|
expect(
|
|
loaded.some((src) => src?.includes("julia-beauty/scrub-desktop.mp4")),
|
|
).toBe(true);
|
|
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
|
});
|
|
|
|
it("only downloads where the scroll settles, not every project it flies past", () => {
|
|
renderReel();
|
|
scrollTo(0);
|
|
// atravessa vários projetos em menos de 300 ms
|
|
for (const project of [1, 2, 3]) {
|
|
reelTop = HEADER - reelOffset(weights, project, 0.5) * SCROLLABLE;
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(60);
|
|
});
|
|
}
|
|
act(() => {
|
|
jest.advanceTimersByTime(400);
|
|
});
|
|
const loaded = sources().filter(Boolean);
|
|
expect(loaded.some((src) => src?.includes("sc-plus"))).toBe(true);
|
|
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false); // vizinho: só o pôster
|
|
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
|
});
|
|
|
|
it("never downloads the phone video, whether or not the phone frame is on screen (it is only a poster)", () => {
|
|
phoneWidth = 0;
|
|
renderReel();
|
|
scrollTo(0);
|
|
act(() => {
|
|
jest.advanceTimersByTime(400);
|
|
});
|
|
const loaded = sources().filter(Boolean);
|
|
expect(
|
|
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
|
).toBe(true);
|
|
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(false);
|
|
});
|
|
|
|
it("asks for posters only near the reel, and only for the project on screen and its neighbours", () => {
|
|
reelTop = 6000;
|
|
renderReel();
|
|
const poster = (index: number) =>
|
|
posterIn(document.querySelectorAll(".ds-reel-screen")[index]);
|
|
expect(poster(0)).toBe("");
|
|
expect(document.querySelectorAll("video[poster]")).toHaveLength(0);
|
|
|
|
scrollTo(0); // chegou a menos de uma tela
|
|
expect(poster(0)).toContain("sev-modaintima/desktop.webp");
|
|
expect(poster(1)).toContain("lavacao-047/desktop.webp");
|
|
expect(poster(3)).toBe("");
|
|
expect(poster(4)).toBe("");
|
|
});
|
|
|
|
it("downloads each poster once, as an optimized image sized for where it shows, never the 1440 px original twice", () => {
|
|
renderReel();
|
|
scrollTo(0);
|
|
|
|
// nenhum pôster vai por imagem de fundo nem pelo atributo do vídeo (cada um era uma requisição do arquivo original)
|
|
for (const screen of document.querySelectorAll<HTMLElement>(
|
|
".ds-reel-screen, .ds-phone-screen",
|
|
))
|
|
expect(screen.style.backgroundImage).toBe("");
|
|
expect(document.querySelectorAll("video[poster]")).toHaveLength(0);
|
|
const posters = [...document.querySelectorAll(".ds-reel img")];
|
|
// o do projeto em cena e o do vizinho, no notebook e no celular
|
|
expect(posters).toHaveLength(4);
|
|
for (const image of posters) {
|
|
// otimizado (next/image: o arquivo é redimensionado pelo servidor) e com o tamanho em que aparece
|
|
expect(image.getAttribute("src")).toContain("/_next/image");
|
|
expect(image.getAttribute("sizes")).toBeTruthy();
|
|
expect(image).toHaveAttribute("alt", "");
|
|
}
|
|
const laptops = posters.filter((image) => image.closest(".ds-reel-screen"));
|
|
const phones = posters.filter((image) => image.closest(".ds-phone-screen"));
|
|
expect(laptops).toHaveLength(2);
|
|
expect(phones).toHaveLength(2);
|
|
for (const image of laptops)
|
|
expect(image.getAttribute("sizes")).toContain("58vw");
|
|
for (const image of phones)
|
|
expect(image.getAttribute("sizes")).toContain("13vw");
|
|
// cada pôster é uma URL só (não há dois pedidos do mesmo arquivo)
|
|
const urls = posters.map((image) => image.getAttribute("src"));
|
|
expect(new Set(urls).size).toBe(urls.length);
|
|
});
|
|
|
|
it("respects the data saver: no scroll video is downloaded", () => {
|
|
Object.defineProperty(navigator, "connection", {
|
|
configurable: true,
|
|
value: { saveData: true },
|
|
});
|
|
renderReel();
|
|
scrollTo(0);
|
|
act(() => {
|
|
jest.advanceTimersByTime(400);
|
|
});
|
|
expect(sources().every((src) => src === null)).toBe(true);
|
|
// o pôster continua lá
|
|
expect(posterIn(document.querySelectorAll(".ds-reel-screen")[0])).toContain(
|
|
"desktop.webp",
|
|
);
|
|
});
|
|
|
|
it("gives the server the project list (text and links, no media) and holds the pinned height before mounting", () => {
|
|
const html = renderToString(
|
|
<ShowcaseProvider projects={projects}>
|
|
<ProjectReel />
|
|
</ShowcaseProvider>,
|
|
);
|
|
// lugar reservado para a vitrine fixa: a página não muda de altura quando ela aparece (salto para #contact)
|
|
expect(html).toContain("ds-reel-placeholder");
|
|
expect(html).toContain("--reel-sum");
|
|
expect(html).toContain('aria-hidden="true"');
|
|
expect(html).not.toContain("<video");
|
|
expect(html).not.toContain("/videos/");
|
|
// sem JavaScript os projetos continuam ali, com o link do case
|
|
expect(html).toContain("ds-reel-list");
|
|
expect(html).toContain("data-pending");
|
|
expect(html).toContain("SEV Moda Íntima");
|
|
expect(html).toContain('href="/cases/lavacao-047"');
|
|
expect(html).not.toContain("Ampliar vídeo");
|
|
});
|
|
|
|
it("has an h2 before the project h3, in the pinned scene and in the list", () => {
|
|
const { unmount } = renderReel();
|
|
const region = screen.getByRole("region", { name: /Vitrine animada/ });
|
|
const order = [...region.querySelectorAll("h2, h3")].map(
|
|
(heading) => heading.tagName,
|
|
);
|
|
expect(order[0]).toBe("H2");
|
|
expect(order).toContain("H3");
|
|
unmount();
|
|
|
|
mockViewport({ phone: true });
|
|
renderReel();
|
|
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
|
expect(list.querySelector("h2, h3")?.tagName).toBe("H2");
|
|
});
|
|
|
|
it("limits each project to two chapters, in the index and in the caption", () => {
|
|
renderReel();
|
|
for (const layer of document.querySelectorAll(".ds-reel-layer"))
|
|
expect(
|
|
layer.querySelectorAll(".ds-reel-ticks li").length,
|
|
).toBeLessThanOrEqual(2);
|
|
const chapters = screen.getByRole("list", {
|
|
name: "Trechos de SEV Moda Íntima",
|
|
});
|
|
expect(within(chapters).getAllByRole("button")).toHaveLength(2);
|
|
});
|
|
|
|
it("shows the real domain in the address bar, or just the project name, never github.com", () => {
|
|
renderReel();
|
|
const bars = [...document.querySelectorAll(".ds-bframe-url")].map(
|
|
(bar) => bar.textContent,
|
|
);
|
|
expect(bars).toContain("sevmodaintima.com.br");
|
|
expect(bars).toContain("scplus.evandro.dev.br");
|
|
expect(bars).toContain("AS Serviços");
|
|
expect(document.body.innerHTML).not.toContain("github.com");
|
|
});
|
|
|
|
it("offers Ver case (outline), Abrir site (text link, only with a live site) and Ampliar vídeo, none of them lime", () => {
|
|
renderReel();
|
|
const actions = caption().querySelector(".ds-reel-actions") as HTMLElement;
|
|
expect(
|
|
within(actions).getByRole("link", { name: /Ver case/ }),
|
|
).toHaveAttribute("href", "/cases/sev-modaintima");
|
|
const site = within(actions).getByRole("link", { name: /Abrir site/ });
|
|
expect(site).toHaveAttribute("href", "https://sevmodaintima.com.br");
|
|
expect(site).toHaveAttribute("target", "_blank");
|
|
expect(site).toHaveClass("ds-link-text");
|
|
expect(
|
|
within(actions).getByRole("button", { name: /Ampliar vídeo/ }),
|
|
).toHaveClass("ds-link-muted");
|
|
expect(document.querySelector(".ds-reel .ds-link-accent")).toBeNull();
|
|
|
|
// sem demoUrl não há "Abrir site" (AS Serviços, 5º projeto)
|
|
scrollTo(reelOffset(weights, 4, 0.5));
|
|
expect(caption()).toHaveTextContent("AS Serviços");
|
|
expect(
|
|
within(caption()).queryByRole("link", { name: /Abrir site/ }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("opens the enlarged video from the pinned scene", () => {
|
|
renderReel();
|
|
fireEvent.click(
|
|
within(caption()).getByRole("button", { name: /Ampliar vídeo/ }),
|
|
);
|
|
expect(
|
|
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("scrolls to the start of a project when its button is pressed", () => {
|
|
renderReel();
|
|
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
|
fireEvent.click(within(nav).getByRole("button", { name: /NutriScan/ }));
|
|
act(() => {
|
|
jest.advanceTimersByTime(1200);
|
|
});
|
|
|
|
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
|
const last = calls[calls.length - 1][0] as { top: number };
|
|
const expected =
|
|
HEADER - HEADER + reelOffset(weights, 2, 0) * SCROLLABLE + 2;
|
|
expect(last.top).toBeCloseTo(expected, 0);
|
|
});
|
|
|
|
it("scrolls to a chapter inside the project when its link is pressed", () => {
|
|
renderReel();
|
|
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
|
fireEvent.click(
|
|
within(nav).getByRole("button", { name: /Coleção completa/ }),
|
|
);
|
|
act(() => {
|
|
jest.advanceTimersByTime(1200);
|
|
});
|
|
|
|
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
|
const last = calls[calls.length - 1][0] as { top: number };
|
|
expect(last.top).toBeGreaterThan(reelOffset(weights, 0, 0) * SCROLLABLE);
|
|
expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE);
|
|
// o 2º capítulo começa na metade da rolagem do projeto (que vai de REEL_HOLD a 1 - REEL_HOLD do trecho dele)
|
|
expect(last.top).toBeCloseTo(
|
|
reelOffset(weights, 0, 0.5) * SCROLLABLE + 2,
|
|
0,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ProjectReel: a troca de projeto não duplica nada na tela", () => {
|
|
/** Move a rolagem para o meio do trecho do projeto e deixa passar só `ms`. */
|
|
function moveTo(index: number, ms: number) {
|
|
reelTop = HEADER - reelOffset(weights, index, 0.5) * SCROLLABLE;
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(ms);
|
|
});
|
|
}
|
|
|
|
const titles = () =>
|
|
[...document.querySelectorAll(".ds-reel-title")].map(
|
|
(title) => title.textContent,
|
|
);
|
|
|
|
it("has one caption for the whole pinned scene: title, counter and buttons exist once, outside the layers", () => {
|
|
renderReel();
|
|
|
|
expect(document.querySelectorAll(".ds-reel-caption")).toHaveLength(1);
|
|
expect(document.querySelectorAll(".ds-reel-title")).toHaveLength(1);
|
|
expect(document.querySelectorAll(".ds-reel-chapter-count")).toHaveLength(1);
|
|
expect(document.querySelectorAll(".ds-reel-actions")).toHaveLength(1);
|
|
expect(document.querySelectorAll(".ds-reel-layer")).toHaveLength(6);
|
|
for (const layer of document.querySelectorAll(".ds-reel-layer")) {
|
|
expect(layer.closest(".ds-reel-caption")).toBeNull();
|
|
expect(layer.querySelector(".ds-reel-caption")).toBeNull();
|
|
expect(layer.querySelector("h3, button, a")).toBeNull();
|
|
}
|
|
// os aparelhos e a legenda dividem o mesmo bloco, a legenda abaixo
|
|
const set = document.querySelector(".ds-reel-set") as HTMLElement;
|
|
expect(set.querySelector(".ds-reel-devices")).toBeInTheDocument();
|
|
expect(set.lastElementChild).toBe(caption());
|
|
});
|
|
|
|
it("each layer holds only the devices: the laptop with its video and the phone", () => {
|
|
renderReel();
|
|
|
|
for (const layer of document.querySelectorAll(".ds-reel-layer")) {
|
|
expect(layer.querySelectorAll(".ds-bframe")).toHaveLength(1);
|
|
expect(layer.querySelectorAll(".ds-phone")).toHaveLength(1);
|
|
expect(layer.querySelectorAll(".ds-reel-screen video")).toHaveLength(1);
|
|
}
|
|
});
|
|
|
|
it("fades the old caption out first, with its old content and no second caption, then brings the new one in", () => {
|
|
renderReel();
|
|
expect(caption()).toHaveTextContent("SEV Moda Íntima");
|
|
expect(caption()).not.toHaveAttribute("data-leaving");
|
|
|
|
// o projeto muda; só uns milissegundos depois (a legenda ainda está saindo)
|
|
moveTo(1, 60);
|
|
expect(activeLayer()).toHaveAttribute("data-state", "active");
|
|
expect(activeLayer().querySelector(".ds-bframe-url")?.textContent).not.toBe(
|
|
"sevmodaintima.com.br",
|
|
);
|
|
expect(caption()).toHaveAttribute("data-leaving");
|
|
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
|
expect(document.querySelectorAll(".ds-reel-actions")).toHaveLength(1);
|
|
|
|
// passou o tempo de saída: o conteúdo novo entra, e a legenda volta
|
|
act(() => {
|
|
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
|
|
});
|
|
expect(titles()).toEqual(["Lavação 047"]);
|
|
expect(caption()).not.toHaveAttribute("data-leaving");
|
|
});
|
|
|
|
it("leaves in 120 ms: before that the old caption is still there, right after it the new one", () => {
|
|
expect(CAPTION_OUT_MS).toBe(120);
|
|
renderReel();
|
|
moveTo(1, 20);
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(CAPTION_OUT_MS - 30);
|
|
});
|
|
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(60);
|
|
});
|
|
expect(titles()).toEqual(["Lavação 047"]);
|
|
});
|
|
|
|
it("at no moment of the change are two project titles (or two sets of buttons) on screen", () => {
|
|
renderReel();
|
|
const seen: number[] = [];
|
|
for (const [index, ms] of [
|
|
[1, 16],
|
|
[1, 50],
|
|
[1, 80],
|
|
[2, 16],
|
|
[2, 100],
|
|
[2, 200],
|
|
[3, 10],
|
|
[0, 10],
|
|
[0, 300],
|
|
] as const) {
|
|
moveTo(index, ms);
|
|
seen.push(
|
|
document.querySelectorAll(".ds-reel-title").length,
|
|
document.querySelectorAll(".ds-reel-actions").length,
|
|
document.querySelectorAll(".ds-reel-chapter-count").length,
|
|
);
|
|
}
|
|
|
|
expect(new Set(seen)).toEqual(new Set([1]));
|
|
});
|
|
|
|
it("keeps coming back to the project in scene when the scroll goes back before the caption finished leaving", () => {
|
|
renderReel();
|
|
|
|
moveTo(1, 40);
|
|
moveTo(0, 40);
|
|
act(() => {
|
|
jest.advanceTimersByTime(400);
|
|
});
|
|
|
|
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
|
expect(caption()).not.toHaveAttribute("data-leaving");
|
|
});
|
|
|
|
it("changes the chapter inside the same project in place, without fading the caption", () => {
|
|
renderReel();
|
|
readyVideos();
|
|
|
|
scrollTo(reelOffset(weights, 0, 0.9)); // fim do primeiro projeto: outro trecho
|
|
|
|
expect(caption()).not.toHaveAttribute("data-leaving");
|
|
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
|
expect(
|
|
caption().querySelector(".ds-reel-chapter-count")?.textContent,
|
|
).not.toBe("01/02");
|
|
});
|
|
|
|
it("never moves the caption text: no inline transform, while it leaves and when it comes back with the new content", () => {
|
|
renderReel();
|
|
moveTo(1, 60);
|
|
|
|
expect(caption().getAttribute("style")).toBeNull();
|
|
for (const element of caption().querySelectorAll("*"))
|
|
expect((element as HTMLElement).style.transform).toBe("");
|
|
|
|
// a legenda sai (atributo, sem estilo inline) e volta com o conteúdo novo sem nunca se deslocar
|
|
expect(caption()).toHaveAttribute("data-leaving");
|
|
act(() => {
|
|
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
|
|
});
|
|
expect(caption()).not.toHaveAttribute("data-leaving");
|
|
expect(caption().getAttribute("style")).toBeNull();
|
|
for (const element of caption().querySelectorAll("*"))
|
|
expect((element as HTMLElement).style.transform).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("ProjectReel: teclado do índice (roving tabindex)", () => {
|
|
const index = () =>
|
|
screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
|
const stops = () =>
|
|
within(index())
|
|
.getAllByRole("button")
|
|
.filter((button) => button.getAttribute("tabindex") === "0");
|
|
const focusOn = (element: HTMLElement) =>
|
|
act(() => {
|
|
element.focus();
|
|
});
|
|
|
|
it("makes the whole index ONE tab stop (the current project), however many projects and chapters it lists", () => {
|
|
renderReel();
|
|
const buttons = within(index()).getAllByRole("button");
|
|
// 6 projetos e os 2 trechos do projeto em cena: 8 botões, uma parada
|
|
expect(buttons).toHaveLength(6 + 2);
|
|
expect(stops()).toHaveLength(1);
|
|
expect(stops()[0]).toHaveAttribute("aria-current", "true");
|
|
expect(stops()[0]).toHaveAccessibleName("SEV Moda Íntima");
|
|
});
|
|
|
|
it("walks projects and the chapters of the current one with the arrows, in the order they appear", () => {
|
|
renderReel();
|
|
const names = () =>
|
|
within(index())
|
|
.getAllByRole("button")
|
|
.map((button) => button.textContent?.replace(/\s+/g, " ").trim());
|
|
focusOn(stops()[0]);
|
|
const seen: Array<string | null | undefined> = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
fireEvent.keyDown(document.activeElement as Element, {
|
|
key: "ArrowDown",
|
|
});
|
|
seen.push(
|
|
document.activeElement?.textContent?.replace(/\s+/g, " ").trim(),
|
|
);
|
|
}
|
|
// depois do projeto em cena vêm os trechos dele, e então os outros projetos
|
|
expect(seen.slice(0, 3)).toEqual(names().slice(1, 4));
|
|
// as setas não passam da última parada
|
|
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
|
const last = document.activeElement;
|
|
fireEvent.keyDown(last as Element, { key: "ArrowDown" });
|
|
expect(document.activeElement).toBe(last);
|
|
});
|
|
|
|
it("moves the tab stop to where the focus went, and Enter picks the project like a click", () => {
|
|
renderReel();
|
|
focusOn(stops()[0]);
|
|
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
|
const target = document.activeElement as HTMLElement;
|
|
expect(stops()).toEqual([target]);
|
|
// o botão do último projeto leva a rolagem até ele (o teclado ativa o clique nativo do botão)
|
|
fireEvent.click(target);
|
|
act(() => {
|
|
jest.advanceTimersByTime(100);
|
|
});
|
|
expect(window.scrollTo).toHaveBeenCalled();
|
|
});
|
|
|
|
describe("the caption actions", () => {
|
|
const tabStopsAll = (root: Element) => [
|
|
...root.querySelectorAll<HTMLElement>("a[href], button"),
|
|
];
|
|
const tabStops = (root: Element) =>
|
|
tabStopsAll(root).filter(
|
|
(element) => element.getAttribute("tabindex") !== "-1",
|
|
);
|
|
|
|
it("are ONE tab stop (Ver case, Ampliar vídeo and Abrir site as a toolbar), so a project costs two stops with the index", () => {
|
|
renderReel();
|
|
const toolbar = within(caption()).getByRole("toolbar", {
|
|
name: "Ações de SEV Moda Íntima",
|
|
});
|
|
|
|
expect(tabStopsAll(toolbar)).toHaveLength(3);
|
|
expect(tabStops(toolbar)).toHaveLength(1);
|
|
expect(tabStops(toolbar)[0]).toHaveAccessibleName(/Ver case/);
|
|
// o índice (uma parada) + as ações (uma parada): a vitrine inteira custa duas paradas, não oito
|
|
const reel = document.querySelector(".ds-reel") as HTMLElement;
|
|
const stops = tabStops(reel).filter(
|
|
(element) => !element.closest("[inert]"),
|
|
);
|
|
expect(stops).toHaveLength(2);
|
|
});
|
|
|
|
it("keep Ampliar vídeo and Abrir site reachable with the arrows, and Enter on Ampliar opens it", () => {
|
|
renderReel();
|
|
const toolbar = within(caption()).getByRole("toolbar");
|
|
act(() => {
|
|
tabStops(toolbar)[0].focus();
|
|
});
|
|
|
|
fireEvent.keyDown(document.activeElement as Element, {
|
|
key: "ArrowRight",
|
|
});
|
|
expect(document.activeElement).toHaveAccessibleName(/Ampliar vídeo/);
|
|
// a parada do Tab foi para onde o foco está
|
|
expect(tabStops(toolbar)).toEqual([document.activeElement]);
|
|
|
|
fireEvent.keyDown(document.activeElement as Element, {
|
|
key: "ArrowRight",
|
|
});
|
|
expect(document.activeElement).toHaveAccessibleName(/Abrir site/);
|
|
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
|
expect(document.activeElement).toHaveAccessibleName(/Abrir site/);
|
|
fireEvent.keyDown(document.activeElement as Element, { key: "Home" });
|
|
expect(document.activeElement).toHaveAccessibleName(/Ver case/);
|
|
|
|
fireEvent.keyDown(document.activeElement as Element, {
|
|
key: "ArrowRight",
|
|
});
|
|
fireEvent.click(document.activeElement as Element);
|
|
expect(
|
|
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("follow a project without a site: two actions, and the stop never lands on the missing one", () => {
|
|
renderReel();
|
|
const toolbar = () => within(caption()).getByRole("toolbar");
|
|
act(() => {
|
|
tabStops(toolbar())[0].focus();
|
|
});
|
|
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
|
expect(document.activeElement).toHaveAccessibleName(/Abrir site/);
|
|
|
|
scrollTo(reelOffset(weights, 4, 0.5)); // AS Serviços: sem site
|
|
act(() => {
|
|
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
|
|
});
|
|
|
|
expect(caption()).toHaveTextContent("AS Serviços");
|
|
expect(tabStopsAll(toolbar())).toHaveLength(2);
|
|
expect(tabStops(toolbar())).toHaveLength(1);
|
|
});
|
|
});
|
|
});
|