feat(projects): video de rolagem na capa dos cards, carregado so quando o card aparece
video autoplay muted loop playsinline preload=none com poster; as fontes entram via IntersectionObserver e o video pausa fora da tela. Com prefers-reduced-motion aparece so o poster. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
ca9c0604b8
commit
a67892613b
6 files changed
+276
-2
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
getProjectVideo,
|
||||
PROJECT_VIDEO_IDS,
|
||||
type ProjectVideoAsset,
|
||||
} from "@/lib/project-videos";
|
||||
import { ProjectVideo } from "./ProjectVideo";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
|
||||
const video: ProjectVideoAsset = {
|
||||
poster: "/videos/demo/desktop.webp",
|
||||
width: 1440,
|
||||
height: 900,
|
||||
sources: [
|
||||
{ src: "/videos/demo/desktop.webm", type: 'video/webm; codecs="vp9"' },
|
||||
{ src: "/videos/demo/desktop.mp4", type: "video/mp4" },
|
||||
],
|
||||
};
|
||||
|
||||
type Callback = IntersectionObserverCallback;
|
||||
let callbacks: Callback[] = [];
|
||||
const originalObserver = global.IntersectionObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
reducedMotion = false;
|
||||
callbacks = [];
|
||||
// observador controlável: o teste decide quando o card entra e sai da tela
|
||||
global.IntersectionObserver = class {
|
||||
constructor(callback: Callback) {
|
||||
callbacks.push(callback);
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.IntersectionObserver = originalObserver;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setVisible(isIntersecting: boolean) {
|
||||
act(() => {
|
||||
for (const callback of callbacks) {
|
||||
callback(
|
||||
[{ isIntersecting } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProjectVideo", () => {
|
||||
it("renders a muted looping inline video that downloads nothing before it is visible", () => {
|
||||
const { container } = render(<ProjectVideo video={video} title="Demo" />);
|
||||
const element = container.querySelector("video");
|
||||
|
||||
expect(element).not.toBeNull();
|
||||
expect(element).toHaveAttribute("autoplay");
|
||||
expect(element).toHaveAttribute("loop");
|
||||
expect(element).toHaveAttribute("playsinline");
|
||||
expect(element).toHaveAttribute("preload", "none");
|
||||
expect(element).toHaveAttribute("poster", video.poster);
|
||||
expect(element?.muted).toBe(true);
|
||||
expect(container.querySelectorAll("source")).toHaveLength(0);
|
||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches the sources and plays once the card enters the viewport, then pauses when it leaves", () => {
|
||||
const { container } = render(<ProjectVideo video={video} title="Demo" />);
|
||||
|
||||
setVisible(true);
|
||||
const sources = [...container.querySelectorAll("source")];
|
||||
expect(sources.map((source) => source.getAttribute("src"))).toEqual([
|
||||
"/videos/demo/desktop.webm",
|
||||
"/videos/demo/desktop.mp4",
|
||||
]);
|
||||
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
|
||||
|
||||
setVisible(false);
|
||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows only the poster, with no video element, when the user prefers reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
const { container } = render(<ProjectVideo video={video} title="Demo" />);
|
||||
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Captura de tela de Demo" }),
|
||||
).toBeInTheDocument();
|
||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectVideo", () => {
|
||||
it("returns undefined for projects without a recorded video", () => {
|
||||
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(PROJECT_VIDEO_IDS)(
|
||||
"has webm, mp4 and poster on disk for %s, each within 3 MB",
|
||||
(id) => {
|
||||
const asset = getProjectVideo(id);
|
||||
expect(asset).toBeDefined();
|
||||
const files = [
|
||||
asset?.poster,
|
||||
...(asset?.sources.map((s) => s.src) ?? []),
|
||||
];
|
||||
for (const file of files) {
|
||||
const path = join(process.cwd(), "public", file ?? "");
|
||||
expect(existsSync(path)).toBe(true);
|
||||
expect(statSync(path).size).toBeLessThanOrEqual(3 * 1024 * 1024);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user