Files
portifolio-evandrodevbr/lib/project-showcase.test.ts
evandro c7d09d63d4
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s
feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
Hero e movimento
- cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py):
  marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao
  cursor e, parada no topo, passa sozinha pelos estados com legenda
- três níveis de aparelho (full, lite, static); sem WebGL fica o pôster
- título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
- faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções
- rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa
  compartilhada entre a lista de cases e o case

Ofertas
- atendimento no WhatsApp com IA e nota fiscal para a reforma tributária
  (páginas de serviço, cartões da home, formulário e aviso com data de validade)
- página /ia atualizada, com fontes
- peças 3D em loop nos cartões e topos de página (3d/pecas.py)

Design
- hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta
- um destino por rótulo: "Falar sobre meu projeto" leva ao formulário,
  "Chamar no WhatsApp" abre o WhatsApp
- logo montado em blocos, 404 redesenhado, grades sem célula vazia
- ícones do PWA e manifesto novos

Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
2026-11-10 03:48:25 -03:00

233 lines
8.6 KiB
TypeScript

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",
);
});
});