import { existsSync, statSync } from "node:fs"; import { join } from "node:path"; import { chapterAnchors, chapterFraction, chapterIndexAt, chapterIndexAtScroll, chapterScrollFraction, desktopFractionAt, getOgImagePath, getScrubSource, getShowcaseAssets, MAX_CHAPTERS, mobileFractionAt, PROJECT_VIDEO_IDS, SHOWCASE, videoFractionAtScroll, } from "./project-showcase"; const MAX_BYTES = 3 * 1024 * 1024; const publicFile = (path: string) => join(process.cwd(), "public", path); /** Os projetos de cliente que a home entrega à vitrine. */ const HOME_IDS = [ "sev-modaintima", "lavacao-047", "sc-plus", "as-servicos", "julia-beauty", ]; describe("showcase length", () => { it.each(SHOWCASE)("$id has at most two chapters", (config) => { expect(MAX_CHAPTERS).toBe(2); expect(config.chapters.length).toBeLessThanOrEqual(MAX_CHAPTERS); expect(config.chapters.length).toBeGreaterThanOrEqual(1); }); it("gives every project the same scroll, half a screen for its two chapters", () => { expect(SHOWCASE.map((config) => config.weight)).toEqual( SHOWCASE.map(() => 0.5), ); }); it("keeps the whole Projects section within about four screens", () => { // o casco da vitrine tem (pin: 1 tela menos o cabeçalho) + (soma dos pesos) telas; o título e o botão de Projetos // somam ~468 px (0,52 tela de 900 px) fora dela, e o cabeçalho fixo tem 68 px const SCREEN = 900; const HEADER = 68; const OUTSIDE = 468; const total = SHOWCASE.filter((config) => HOME_IDS.includes(config.id)) .map((config) => config.weight) .reduce((sum, weight) => sum + weight, 0); expect(HOME_IDS.every((id) => SHOWCASE.some((c) => c.id === id))).toBe( true, ); const section = SCREEN - HEADER + total * SCREEN + OUTSIDE; expect(section / SCREEN).toBeLessThanOrEqual(4); expect(total).toBeGreaterThan(1.5); }); it("keeps the NutriScan configuration for /cases", () => { const nutriscan = SHOWCASE.find((config) => config.id === "nutriscan"); expect(nutriscan).toBeDefined(); expect(PROJECT_VIDEO_IDS).toContain("nutriscan"); expect(getOgImagePath("nutriscan")).toBe("/videos/nutriscan/og.jpg"); }); }); describe("showcase chapters", () => { it.each(SHOWCASE)("$id has ordered chapters inside both videos", (config) => { const [first] = config.chapters; expect(first.desktop).toBe(0); expect(first.mobile).toBe(0); config.chapters.forEach((chapter, index) => { expect(chapter.label.trim()).not.toBe(""); expect(chapter.desktop).toBeLessThan(config.durations.desktop); expect(chapter.mobile).toBeLessThan(config.durations.mobile); if (index > 0) { expect(chapter.desktop).toBeGreaterThan( config.chapters[index - 1].desktop, ); expect(chapter.mobile).toBeGreaterThan( config.chapters[index - 1].mobile, ); } }); expect(config.weight).toBeGreaterThan(0); }); it.each(SHOWCASE)("$id keeps the two timelines in sync", (config) => { const anchors = chapterAnchors(config); expect(anchors[0]).toEqual([0, 0]); expect(anchors[anchors.length - 1]).toEqual([1, 1]); for (const fraction of [0, 0.13, 0.5, 0.77, 1]) { const mobile = mobileFractionAt(config, fraction); expect(mobile).toBeGreaterThanOrEqual(0); expect(mobile).toBeLessThanOrEqual(1); expect(desktopFractionAt(config, mobile)).toBeCloseTo(fraction, 9); } config.chapters.forEach((_, index) => { expect( mobileFractionAt(config, chapterFraction(config, index, "desktop")), ).toBeCloseTo(chapterFraction(config, index, "mobile"), 9); }); }); it("finds the chapter on screen", () => { const lavacao = SHOWCASE.find((config) => config.id === "lavacao-047"); expect(lavacao).toBeDefined(); if (!lavacao) return; expect(chapterIndexAt(lavacao, 0)).toBe(0); expect(chapterIndexAt(lavacao, 3 / lavacao.durations.desktop)).toBe(0); expect(chapterIndexAt(lavacao, 10 / lavacao.durations.desktop)).toBe(1); expect(chapterIndexAt(lavacao, 25 / lavacao.durations.desktop)).toBe(1); expect(chapterIndexAt(lavacao, 1)).toBe(lavacao.chapters.length - 1); expect(chapterIndexAt(lavacao, 2)).toBe(lavacao.chapters.length - 1); expect(chapterIndexAt(lavacao, -1)).toBe(0); }); }); describe("showcase scroll: every chapter gets the same share", () => { const lavacao = SHOWCASE.find((config) => config.id === "lavacao-047"); const asServicos = SHOWCASE.find((config) => config.id === "as-servicos"); if (!lavacao || !asServicos) throw new Error("projeto fora da vitrine"); it.each(SHOWCASE)( "$id: each chapter starts at its own time of the video, at i/n of the scroll", (config) => { config.chapters.forEach((chapter, i) => { expect( videoFractionAtScroll(config, chapterScrollFraction(config, i)), ).toBeCloseTo(chapter.desktop / config.durations.desktop, 9); }); expect(videoFractionAtScroll(config, 0)).toBe(0); expect(videoFractionAtScroll(config, 1)).toBe(1); }, ); it("splits a 30 s project with a 5.5 s first chapter in half of the scroll, not in 18% and 82%", () => { const half = videoFractionAtScroll(lavacao, 0.5); expect(half).toBeCloseTo(5.5 / lavacao.durations.desktop, 9); // dentro de cada metade o vídeo anda de forma linear expect(videoFractionAtScroll(lavacao, 0.25)).toBeCloseTo(half / 2, 9); expect(videoFractionAtScroll(lavacao, 0.75)).toBeCloseTo((half + 1) / 2, 9); }); it("only goes forward, even for a project whose chapters are very different in length", () => { let last = -1; for (let progress = 0; progress <= 1; progress += 0.01) { const fraction = videoFractionAtScroll(asServicos, progress); expect(fraction).toBeGreaterThanOrEqual(last); last = fraction; } }); it("reads the chapter from the scroll: n equal parts, clamped", () => { expect(chapterIndexAtScroll(lavacao, 0)).toBe(0); expect(chapterIndexAtScroll(lavacao, 0.49)).toBe(0); expect(chapterIndexAtScroll(lavacao, 0.5)).toBe(1); expect(chapterIndexAtScroll(lavacao, 1)).toBe(lavacao.chapters.length - 1); expect(chapterIndexAtScroll(lavacao, 3)).toBe(lavacao.chapters.length - 1); expect(chapterIndexAtScroll(lavacao, -1)).toBe(0); // a conta de ponto flutuante na fronteira (1/3 de 3 partes) não derruba o capítulo const three = { ...lavacao, chapters: [...lavacao.chapters, lavacao.chapters[1]], }; expect(chapterIndexAtScroll(three, 1 / 3)).toBe(1); }); it("gives the scroll fraction where a chapter starts (and clamps the index)", () => { expect(chapterScrollFraction(lavacao, 0)).toBe(0); expect(chapterScrollFraction(lavacao, 1)).toBe(0.5); expect(chapterScrollFraction(lavacao, 9)).toBe(0.5); expect(chapterScrollFraction(lavacao, -2)).toBe(0); }); }); describe("showcase assets", () => { it("picks the version for the device", () => { expect(getShowcaseAssets("nutriscan", "desktop").poster).toBe( "/videos/nutriscan/desktop.webp", ); const mobile = getShowcaseAssets("nutriscan", "mobile"); expect(mobile.poster).toBe("/videos/nutriscan/mobile.webp"); expect(mobile.sources.map((source) => source.src)).toEqual([ "/videos/nutriscan/mobile.webm", "/videos/nutriscan/mobile.mp4", ]); }); it.each(PROJECT_VIDEO_IDS)( "has every file on disk for %s, each video within 3 MB", (id) => { const files = [ ...(["desktop", "mobile"] as const).flatMap((device) => { const assets = getShowcaseAssets(id, device); return [assets.poster, ...assets.sources.map((s) => s.src)]; }), getScrubSource(id), getOgImagePath(id), ]; for (const file of files) { expect(existsSync(publicFile(file))).toBe(true); expect(statSync(publicFile(file)).size).toBeLessThanOrEqual(MAX_BYTES); } }, ); it.each(PROJECT_VIDEO_IDS)( "does not keep a scrub video for the phone frame of %s (it only shows the poster)", (id) => { expect(existsSync(publicFile(`/videos/${id}/scrub-mobile.mp4`))).toBe( false, ); }, ); }); describe("project kicker (the label above each project in the showcase)", () => { it("writes category and status as a sentence, keeping acronyms as they are", () => { const { projectKicker } = jest.requireActual("./project-showcase"); expect(projectKicker("loja virtual", "Em Produção")).toBe( "Loja virtual · em produção", ); expect(projectKicker("IoT", "Protótipo")).toBe("IoT · protótipo"); expect(projectKicker("integração com API", "Concluído")).toBe( "Integração com API · concluído", ); }); });