836 lines
25 KiB
TypeScript
836 lines
25 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 } from "@/lib/project-showcase";
|
|
import { ProjectReel } from "./ProjectReel";
|
|
import { ShowcaseProvider } from "./ShowcaseProvider";
|
|
import { reelOffset } 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,
|
|
}));
|
|
|
|
/**
|
|
* Testes de caminhos-base de ProjectReel (complemento de ProjectReel.test.tsx):
|
|
* - qual versão da vitrine entra (fixa só com largura e altura de desktop e sem movimento reduzido; lista em
|
|
* janela baixa; carrossel no celular; nada sem projetos) e a troca ao vivo quando a janela muda;
|
|
* - measure(): vitrine escondida por CSS, trilho sem rolagem;
|
|
* - a faixa de projetos que acompanha o ativo, a descarga de vídeos, o seek dos vídeos, o "prime" e a rolagem suave.
|
|
*/
|
|
|
|
let reducedMotion = false;
|
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
|
useReducedMotion: () => reducedMotion,
|
|
}));
|
|
|
|
const HEADER = 60;
|
|
let containerHeight = 7000;
|
|
let pinHeight = 800;
|
|
let reelTop = HEADER;
|
|
let phoneWidth = 90;
|
|
/** Rolagem horizontal da faixa de projetos (tablet e celular). */
|
|
let stripScrollWidth = 0;
|
|
let stripClientWidth = 0;
|
|
let buttonOffsetLeft = 0;
|
|
let mediaNetworkState = 0;
|
|
|
|
type Listener = () => void;
|
|
const mediaState: Record<string, boolean> = {};
|
|
const mediaListeners = new Map<string, Set<Listener>>();
|
|
const originalMatchMedia = window.matchMedia;
|
|
|
|
function mockViewport({
|
|
phone = false,
|
|
pinned = !phone,
|
|
}: {
|
|
phone?: boolean;
|
|
pinned?: boolean;
|
|
} = {}) {
|
|
mediaState[MOBILE_QUERY] = phone;
|
|
mediaState[PINNED_QUERY] = pinned;
|
|
mediaListeners.clear();
|
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
|
get matches() {
|
|
return Boolean(mediaState[query]);
|
|
},
|
|
media: query,
|
|
addEventListener: (_type: string, listener: Listener) => {
|
|
if (!mediaListeners.has(query)) mediaListeners.set(query, new Set());
|
|
mediaListeners.get(query)?.add(listener);
|
|
},
|
|
removeEventListener: (_type: string, listener: Listener) => {
|
|
mediaListeners.get(query)?.delete(listener);
|
|
},
|
|
}));
|
|
}
|
|
|
|
function changeMedia(query: string, matches: boolean) {
|
|
mediaState[query] = matches;
|
|
act(() => {
|
|
for (const listener of mediaListeners.get(query) ?? []) listener();
|
|
});
|
|
}
|
|
|
|
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 descriptors = {
|
|
offsetHeight: Object.getOwnPropertyDescriptor(
|
|
HTMLElement.prototype,
|
|
"offsetHeight",
|
|
),
|
|
offsetWidth: Object.getOwnPropertyDescriptor(
|
|
HTMLElement.prototype,
|
|
"offsetWidth",
|
|
),
|
|
offsetLeft: Object.getOwnPropertyDescriptor(
|
|
HTMLElement.prototype,
|
|
"offsetLeft",
|
|
),
|
|
scrollWidth: Object.getOwnPropertyDescriptor(
|
|
Element.prototype,
|
|
"scrollWidth",
|
|
),
|
|
clientWidth: Object.getOwnPropertyDescriptor(
|
|
Element.prototype,
|
|
"clientWidth",
|
|
),
|
|
networkState: Object.getOwnPropertyDescriptor(
|
|
HTMLMediaElement.prototype,
|
|
"networkState",
|
|
),
|
|
};
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
if (this.classList.contains("ds-reel")) return containerHeight;
|
|
if (this.classList.contains("ds-reel-pin")) return pinHeight;
|
|
return 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
return this.classList.contains("ds-reel-phone") ? phoneWidth : 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, "offsetLeft", {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
return this.tagName === "BUTTON" ? buttonOffsetLeft : 0;
|
|
},
|
|
});
|
|
Object.defineProperty(Element.prototype, "scrollWidth", {
|
|
configurable: true,
|
|
get(this: Element) {
|
|
return this.matches(".ds-reel-index ol") ? stripScrollWidth : 0;
|
|
},
|
|
});
|
|
Object.defineProperty(Element.prototype, "clientWidth", {
|
|
configurable: true,
|
|
get(this: Element) {
|
|
return this.matches(".ds-reel-index ol") ? stripClientWidth : 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "networkState", {
|
|
configurable: true,
|
|
get: () => mediaNetworkState,
|
|
});
|
|
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 + containerHeight,
|
|
height: containerHeight,
|
|
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;
|
|
for (const [key, target] of [
|
|
["offsetHeight", HTMLElement.prototype],
|
|
["offsetWidth", HTMLElement.prototype],
|
|
["offsetLeft", HTMLElement.prototype],
|
|
["scrollWidth", Element.prototype],
|
|
["clientWidth", Element.prototype],
|
|
["networkState", HTMLMediaElement.prototype],
|
|
] as const) {
|
|
const original = descriptors[key];
|
|
if (original) Object.defineProperty(target, key, original);
|
|
else Reflect.deleteProperty(target, key);
|
|
}
|
|
});
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
reducedMotion = false;
|
|
containerHeight = 7000;
|
|
pinHeight = 800;
|
|
reelTop = HEADER;
|
|
phoneWidth = 90;
|
|
stripScrollWidth = 0;
|
|
stripClientWidth = 0;
|
|
buttonOffsetLeft = 0;
|
|
mediaNetworkState = 0;
|
|
mockViewport();
|
|
(window.scrollTo as jest.Mock).mockClear();
|
|
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
|
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
|
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
jest.restoreAllMocks();
|
|
window.matchMedia = originalMatchMedia;
|
|
Reflect.deleteProperty(navigator, "userAgent");
|
|
Object.defineProperty(navigator, "connection", {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
});
|
|
|
|
function renderReel(list = projects) {
|
|
const view = render(
|
|
<ShowcaseProvider projects={list}>
|
|
<ProjectReel />
|
|
</ShowcaseProvider>,
|
|
);
|
|
act(() => {
|
|
jest.advanceTimersByTime(50);
|
|
});
|
|
return view;
|
|
}
|
|
|
|
function scrollTo(progress: number) {
|
|
const scrollable = containerHeight - pinHeight;
|
|
reelTop = HEADER - progress * scrollable;
|
|
act(() => {
|
|
window.dispatchEvent(new Event("scroll"));
|
|
jest.advanceTimersByTime(1500);
|
|
});
|
|
}
|
|
|
|
const reelEl = () => document.querySelector(".ds-reel");
|
|
const listEl = () =>
|
|
screen.queryByRole("region", { name: "Projetos de clientes" });
|
|
const activeLayer = () =>
|
|
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
|
const caption = () => document.querySelector(".ds-reel-caption") as HTMLElement;
|
|
|
|
describe("ProjectReel: qual versão entra", () => {
|
|
it("path: no projects in the showcase -> renders nothing at all", () => {
|
|
const { container } = renderReel([]);
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
|
|
it("path: wide and tall window, motion allowed -> the pinned reel, not the list", () => {
|
|
renderReel();
|
|
|
|
expect(reelEl()).not.toBeNull();
|
|
expect(listEl()).toBeNull();
|
|
});
|
|
|
|
it("path: wide but short window (below 561 px of height) -> desktop list, nothing pinned", () => {
|
|
mockViewport({ phone: false, pinned: false });
|
|
|
|
renderReel();
|
|
|
|
expect(reelEl()).toBeNull();
|
|
const list = listEl() as HTMLElement;
|
|
expect(list).not.toBeNull();
|
|
expect(list.querySelectorAll("li[data-device='desktop']")).toHaveLength(6);
|
|
expect(list.querySelector(".ds-phone")).toBeNull();
|
|
expect(list).not.toHaveAttribute("data-pending");
|
|
});
|
|
|
|
it("path: phone -> carousel list with the phone version of every item", () => {
|
|
mockViewport({ phone: true });
|
|
|
|
renderReel();
|
|
|
|
expect(reelEl()).toBeNull();
|
|
expect(
|
|
(listEl() as HTMLElement).querySelectorAll("li[data-device='mobile']"),
|
|
).toHaveLength(6);
|
|
});
|
|
|
|
it("path: phone width and reduced motion at once -> still the list", () => {
|
|
mockViewport({ phone: true });
|
|
reducedMotion = true;
|
|
|
|
renderReel();
|
|
|
|
expect(reelEl()).toBeNull();
|
|
expect(listEl()).not.toBeNull();
|
|
});
|
|
|
|
it("path: server render -> the list marked as pending plus the placeholder, with no media", () => {
|
|
const html = renderToString(
|
|
<ShowcaseProvider projects={projects}>
|
|
<ProjectReel />
|
|
</ShowcaseProvider>,
|
|
);
|
|
|
|
expect(html).toContain("data-pending");
|
|
expect(html).toContain("ds-reel-placeholder");
|
|
expect(html).not.toContain("<video");
|
|
expect(html).not.toContain("<img");
|
|
expect(html).not.toContain('data-device="');
|
|
});
|
|
|
|
it("path: the window shrinks below the pinned size -> the reel gives way to the list, and back", () => {
|
|
renderReel();
|
|
expect(reelEl()).not.toBeNull();
|
|
|
|
changeMedia(PINNED_QUERY, false);
|
|
expect(reelEl()).toBeNull();
|
|
expect(listEl()).not.toBeNull();
|
|
|
|
changeMedia(PINNED_QUERY, true);
|
|
expect(reelEl()).not.toBeNull();
|
|
expect(listEl()).toBeNull();
|
|
});
|
|
|
|
it("path: the window crosses the phone breakpoint while the list shows -> the list switches versions", () => {
|
|
mockViewport({ phone: false, pinned: false });
|
|
renderReel();
|
|
expect(
|
|
(listEl() as HTMLElement).querySelectorAll("li[data-device='desktop']"),
|
|
).toHaveLength(6);
|
|
|
|
changeMedia(MOBILE_QUERY, true);
|
|
|
|
expect(
|
|
(listEl() as HTMLElement).querySelectorAll("li[data-device='mobile']"),
|
|
).toHaveLength(6);
|
|
});
|
|
});
|
|
|
|
describe("ProjectReel: measure()", () => {
|
|
it("path: the reel is hidden by CSS (height 0) -> nothing is measured or downloaded (and it is when visible)", () => {
|
|
const attachedSources = () =>
|
|
[...document.querySelectorAll(".ds-reel-layer video")].filter((video) =>
|
|
video.getAttribute("src"),
|
|
);
|
|
containerHeight = 0;
|
|
const hidden = renderReel();
|
|
scrollTo(0.5);
|
|
expect(attachedSources()).toHaveLength(0);
|
|
hidden.unmount();
|
|
|
|
containerHeight = 7000;
|
|
renderReel();
|
|
scrollTo(0.5);
|
|
expect(attachedSources().length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("path: the pinned scene is as tall as the track (nothing to scroll) -> stays on the first project", () => {
|
|
containerHeight = pinHeight;
|
|
renderReel();
|
|
|
|
scrollTo(0.9);
|
|
|
|
expect(activeLayer().querySelector(".ds-bframe-url")).toHaveTextContent(
|
|
"sevmodaintima.com.br",
|
|
);
|
|
expect(caption()).toHaveTextContent("SEV Moda Íntima");
|
|
});
|
|
|
|
it("path: scrolled to a later project -> that project becomes the active layer", () => {
|
|
renderReel();
|
|
|
|
scrollTo(reelOffset(WEIGHTS, 3, 0.5));
|
|
|
|
expect(
|
|
document.querySelector('.ds-reel-index [aria-current="true"]'),
|
|
).toHaveAttribute("aria-label", "SC Plus Aquicultura");
|
|
});
|
|
});
|
|
|
|
describe("ProjectReel: faixa de projetos que acompanha o ativo", () => {
|
|
it("path: strip wider than its box -> scrolls the active button into view (minus 16 px)", () => {
|
|
stripScrollWidth = 900;
|
|
stripClientWidth = 300;
|
|
buttonOffsetLeft = 140;
|
|
const scrollToStrip = jest.fn();
|
|
HTMLOListElement.prototype.scrollTo = scrollToStrip;
|
|
try {
|
|
renderReel();
|
|
|
|
expect(scrollToStrip).toHaveBeenCalledWith({
|
|
left: 124,
|
|
behavior: "smooth",
|
|
});
|
|
} finally {
|
|
Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo");
|
|
}
|
|
});
|
|
|
|
it("path: the active button sits near the start -> never scrolls to a negative position", () => {
|
|
stripScrollWidth = 900;
|
|
stripClientWidth = 300;
|
|
buttonOffsetLeft = 4;
|
|
const scrollToStrip = jest.fn();
|
|
HTMLOListElement.prototype.scrollTo = scrollToStrip;
|
|
try {
|
|
renderReel();
|
|
|
|
expect(scrollToStrip).toHaveBeenCalledWith({
|
|
left: 0,
|
|
behavior: "smooth",
|
|
});
|
|
} finally {
|
|
Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo");
|
|
}
|
|
});
|
|
|
|
it("path: strip fits in its box -> no scrolling", () => {
|
|
stripScrollWidth = 300;
|
|
stripClientWidth = 300;
|
|
const scrollToStrip = jest.fn();
|
|
HTMLOListElement.prototype.scrollTo = scrollToStrip;
|
|
try {
|
|
renderReel();
|
|
|
|
expect(scrollToStrip).not.toHaveBeenCalled();
|
|
} finally {
|
|
Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo");
|
|
}
|
|
});
|
|
|
|
it("path: the browser has no Element.scrollTo -> no error and no scrolling", () => {
|
|
stripScrollWidth = 900;
|
|
stripClientWidth = 300;
|
|
|
|
expect(() => renderReel()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("ProjectReel: vídeos", () => {
|
|
const videos = () => [
|
|
...document.querySelectorAll<HTMLVideoElement>(".ds-reel-layer video"),
|
|
];
|
|
|
|
function ready(
|
|
video: HTMLVideoElement,
|
|
{ readyState = 4, duration = 30, seeking = false } = {},
|
|
) {
|
|
Object.defineProperty(video, "readyState", {
|
|
configurable: true,
|
|
value: readyState,
|
|
});
|
|
Object.defineProperty(video, "duration", {
|
|
configurable: true,
|
|
value: duration,
|
|
});
|
|
Object.defineProperty(video, "seeking", {
|
|
configurable: true,
|
|
value: seeking,
|
|
});
|
|
let time = 0;
|
|
Object.defineProperty(video, "currentTime", {
|
|
configurable: true,
|
|
get: () => time,
|
|
set: (value: number) => {
|
|
time = value;
|
|
},
|
|
});
|
|
}
|
|
|
|
it("path: videos that hold a file reference but are not attached -> released with load()", () => {
|
|
mediaNetworkState = 1;
|
|
reelTop = 6000; // longe da vitrine: ninguém fica anexado
|
|
|
|
renderReel();
|
|
|
|
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
|
|
});
|
|
|
|
it("path: videos that never loaded anything (network empty) -> nothing to release", () => {
|
|
mediaNetworkState = 0;
|
|
reelTop = 6000;
|
|
|
|
renderReel();
|
|
|
|
expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("path: the attached project keeps its file while the others are released", () => {
|
|
mediaNetworkState = 1;
|
|
renderReel();
|
|
scrollTo(0);
|
|
jest.mocked(HTMLMediaElement.prototype.load).mockClear();
|
|
act(() => {
|
|
jest.advanceTimersByTime(400);
|
|
});
|
|
|
|
const attached = videos().filter((video) => video.getAttribute("src"));
|
|
expect(attached.length).toBeGreaterThan(0);
|
|
// quem soltou foi pedido a recarregar; quem ficou com src não foi tocado
|
|
const loaded = jest.mocked(HTMLMediaElement.prototype.load).mock
|
|
.instances as unknown as HTMLMediaElement[];
|
|
for (const video of attached) expect(loaded).not.toContain(video);
|
|
});
|
|
|
|
it("path: a video that is still seeking -> its time is left alone", () => {
|
|
renderReel();
|
|
for (const video of videos()) ready(video, { seeking: true });
|
|
|
|
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
|
|
|
expect(videos()[0].currentTime).toBe(0);
|
|
});
|
|
|
|
it("path: a video whose metadata has not loaded (readyState 0) or has no duration -> never sought", () => {
|
|
renderReel();
|
|
const [first, second] = videos();
|
|
ready(first, { readyState: 0 });
|
|
ready(second, { duration: 0 });
|
|
|
|
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
|
|
|
expect(first.currentTime).toBe(0);
|
|
expect(second.currentTime).toBe(0);
|
|
});
|
|
|
|
it("path: a video that is ready and idle -> sought to the scroll position", () => {
|
|
renderReel();
|
|
for (const video of videos()) ready(video);
|
|
|
|
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
|
|
|
expect(videos()[0].currentTime).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("path: Chrome (anything but WebKit) -> the video never plays: playing downloads the whole file, scrubbing only asks for what is seen", async () => {
|
|
renderReel();
|
|
const video = videos()[0];
|
|
|
|
fireEvent(video, new Event("loadeddata"));
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
|
expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled();
|
|
expect(video.dataset.primed).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"iPhone (Safari)",
|
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
|
|
],
|
|
[
|
|
"iPhone (Chrome, also WebKit)",
|
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0 Mobile/15E148 Safari/604.1",
|
|
],
|
|
[
|
|
"Safari on the Mac",
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
|
|
],
|
|
])(
|
|
"path: %s (WebKit) -> primed once (play then pause) even if loadeddata fires twice",
|
|
async (_name, userAgent) => {
|
|
Object.defineProperty(navigator, "userAgent", {
|
|
configurable: true,
|
|
value: userAgent,
|
|
});
|
|
renderReel();
|
|
const video = videos()[0];
|
|
|
|
fireEvent(video, new Event("loadeddata"));
|
|
fireEvent(video, new Event("loadeddata"));
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1);
|
|
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1);
|
|
expect(video.dataset.primed).toBe("true");
|
|
},
|
|
);
|
|
|
|
it("path: priming is refused by the browser -> swallowed, no pause", async () => {
|
|
Object.defineProperty(navigator, "userAgent", {
|
|
configurable: true,
|
|
value:
|
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
|
|
});
|
|
jest
|
|
.mocked(HTMLMediaElement.prototype.play)
|
|
.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
|
|
renderReel();
|
|
|
|
fireEvent(videos()[0], new Event("loadeddata"));
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("path: metadata loads before any scroll work (reel hidden by CSS, nothing measured) -> sought to the start, no error", () => {
|
|
containerHeight = 0;
|
|
renderReel();
|
|
const video = videos()[0];
|
|
ready(video);
|
|
|
|
expect(() => fireEvent(video, new Event("loadedmetadata"))).not.toThrow();
|
|
|
|
expect(video.currentTime).toBe(0);
|
|
});
|
|
|
|
it("path: metadata loads when the scroll target is known but no frame ran yet (reel far away) -> uses the target", () => {
|
|
reelTop = 6000;
|
|
renderReel();
|
|
const video = videos()[0];
|
|
ready(video);
|
|
|
|
fireEvent(video, new Event("loadedmetadata"));
|
|
|
|
expect(video.currentTime).toBe(0);
|
|
});
|
|
|
|
it("path: metadata loads after the scroll already moved -> the video jumps to where the scroll is", () => {
|
|
renderReel();
|
|
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
|
const video = videos()[0];
|
|
ready(video);
|
|
|
|
fireEvent(video, new Event("loadedmetadata"));
|
|
|
|
expect(video.currentTime).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("ProjectReel: estado das camadas, traços de capítulo e legenda", () => {
|
|
const states = (selector: string, attribute = "data-state") =>
|
|
[...document.querySelectorAll(selector)].map((element) =>
|
|
element.getAttribute(attribute),
|
|
);
|
|
|
|
it("path: a middle project in scene -> layers before it are 'before', the ones after are 'after'", () => {
|
|
renderReel();
|
|
|
|
scrollTo(reelOffset(WEIGHTS, 2, 0.5));
|
|
|
|
const layers = states(".ds-reel-layer");
|
|
expect(layers.length).toBeGreaterThan(3);
|
|
expect(layers).toEqual(
|
|
layers.map((_, i) => (i < 2 ? "before" : i === 2 ? "active" : "after")),
|
|
);
|
|
});
|
|
|
|
it("path: first project, first chapter -> the ticks read now, todo, todo", () => {
|
|
renderReel();
|
|
|
|
scrollTo(0);
|
|
|
|
expect(states(".ds-reel-ticks li")).toEqual(["now", "todo"]);
|
|
expect(caption().querySelector(".ds-reel-chapter-count")).toHaveTextContent(
|
|
"01/02",
|
|
);
|
|
});
|
|
|
|
it("path: end of the project -> the chapter ticks before the current one are 'done', the ones after 'todo'", () => {
|
|
renderReel();
|
|
|
|
scrollTo(reelOffset(WEIGHTS, 0, 0.95));
|
|
|
|
const ticks = states(".ds-reel-ticks li");
|
|
const current = ticks.indexOf("now");
|
|
expect(current).toBeGreaterThan(0);
|
|
expect(ticks).toEqual(
|
|
ticks.map((_, i) =>
|
|
i < current ? "done" : i === current ? "now" : "todo",
|
|
),
|
|
);
|
|
expect(ticks.filter((state) => state === "done")).toHaveLength(current);
|
|
});
|
|
|
|
it("path: project with a subtitle -> shown under the title", () => {
|
|
expect(projects[0].subtitle).toBeTruthy();
|
|
renderReel();
|
|
|
|
expect(caption().querySelector(".ds-reel-sub")).toHaveTextContent(
|
|
projects[0].subtitle,
|
|
);
|
|
});
|
|
|
|
it("path: project without a subtitle -> no empty subtitle line in the caption", () => {
|
|
renderReel([{ ...projects[0], subtitle: "" }, ...projects.slice(1)]);
|
|
|
|
expect(caption().querySelector(".ds-reel-sub")).toBeNull();
|
|
expect(caption().querySelector(".ds-reel-title")).toHaveTextContent(
|
|
projects[0].title,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ProjectReel: rolagem suave até um projeto", () => {
|
|
function pressProject(name: string) {
|
|
fireEvent.click(
|
|
within(
|
|
screen.getByRole("navigation", { name: "Projetos da vitrine" }),
|
|
).getByRole("button", { name }),
|
|
);
|
|
}
|
|
|
|
it("path: pressing a project -> scrolls in animation frames and ends exactly on the target", () => {
|
|
renderReel();
|
|
|
|
pressProject("SC Plus Aquicultura");
|
|
act(() => {
|
|
jest.advanceTimersByTime(2000);
|
|
});
|
|
|
|
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
|
expect(calls.length).toBeGreaterThan(2);
|
|
for (const [options] of calls) expect(options.behavior).toBe("instant");
|
|
const tops = calls.map(([options]) => options.top as number);
|
|
expect(tops).toEqual([...tops].sort((a, b) => a - b));
|
|
expect(tops[tops.length - 1]).toBeGreaterThan(tops[0]);
|
|
});
|
|
|
|
it("path: the wheel during the scroll -> the animation stops where it is", () => {
|
|
renderReel();
|
|
pressProject("SC Plus Aquicultura");
|
|
act(() => {
|
|
jest.advanceTimersByTime(100);
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new Event("wheel"));
|
|
});
|
|
const stoppedAt = (window.scrollTo as jest.Mock).mock.calls.length;
|
|
act(() => {
|
|
jest.advanceTimersByTime(2000);
|
|
});
|
|
|
|
expect((window.scrollTo as jest.Mock).mock.calls).toHaveLength(stoppedAt);
|
|
});
|
|
|
|
it("path: touching the screen during the scroll -> the animation stops too", () => {
|
|
renderReel();
|
|
pressProject("SC Plus Aquicultura");
|
|
act(() => {
|
|
jest.advanceTimersByTime(100);
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new Event("touchstart"));
|
|
});
|
|
const stoppedAt = (window.scrollTo as jest.Mock).mock.calls.length;
|
|
act(() => {
|
|
jest.advanceTimersByTime(2000);
|
|
});
|
|
|
|
expect((window.scrollTo as jest.Mock).mock.calls).toHaveLength(stoppedAt);
|
|
});
|
|
|
|
it("path: a second press while the first scroll runs -> the first is cancelled, only the new target is reached", () => {
|
|
renderReel();
|
|
pressProject("AS Serviços");
|
|
act(() => {
|
|
jest.advanceTimersByTime(100);
|
|
});
|
|
(window.scrollTo as jest.Mock).mockClear();
|
|
|
|
pressProject("Lavação 047");
|
|
act(() => {
|
|
jest.advanceTimersByTime(2000);
|
|
});
|
|
|
|
const tops = (window.scrollTo as jest.Mock).mock.calls.map(
|
|
([options]) => options.top as number,
|
|
);
|
|
const finalTop = tops[tops.length - 1];
|
|
// o destino da Lavação (2º projeto) fica bem antes do da AS Serviços (5º)
|
|
expect(finalTop).toBeLessThan(reelTopFor(4));
|
|
});
|
|
|
|
function reelTopFor(index: number) {
|
|
const scrollable = containerHeight - pinHeight;
|
|
return reelOffset(WEIGHTS, index, 0) * scrollable;
|
|
}
|
|
});
|
|
|
|
describe("ProjectReel: altura do cabeçalho descontada na rolagem até um projeto", () => {
|
|
function pressAndLand(name: string) {
|
|
(window.scrollTo as jest.Mock).mockClear();
|
|
const view = renderReel();
|
|
fireEvent.click(
|
|
within(
|
|
screen.getByRole("navigation", { name: "Projetos da vitrine" }),
|
|
).getByRole("button", { name }),
|
|
);
|
|
act(() => {
|
|
jest.advanceTimersByTime(2000);
|
|
});
|
|
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
|
view.unmount();
|
|
return calls[calls.length - 1][0].top as number;
|
|
}
|
|
|
|
function setWidth(width: number) {
|
|
Object.defineProperty(window, "innerWidth", {
|
|
configurable: true,
|
|
value: width,
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
document.documentElement.style.removeProperty("--site-header-height");
|
|
});
|
|
|
|
it("path: no --site-header-height -> 60 px; set later at the same window width -> still the cached value; a new width -> read again", () => {
|
|
setWidth(1111);
|
|
const withoutVariable = pressAndLand("SC Plus Aquicultura");
|
|
|
|
document.documentElement.style.setProperty("--site-header-height", "80px");
|
|
const sameWidth = pressAndLand("SC Plus Aquicultura");
|
|
expect(sameWidth).toBe(withoutVariable);
|
|
|
|
setWidth(1112);
|
|
const newWidth = pressAndLand("SC Plus Aquicultura");
|
|
// o cabeçalho de 80 px deixa 20 px a menos de rolagem do que o de 60 px
|
|
expect(newWidth).toBe(withoutVariable - 20);
|
|
});
|
|
});
|