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.
263 lines
9.9 KiB
TypeScript
263 lines
9.9 KiB
TypeScript
import { fireEvent, render, screen } from "@testing-library/react";
|
|
|
|
import Hero from "./Hero";
|
|
import { HERO_CAPTIONS } from "./hero-captions";
|
|
|
|
// o Link do Next, trocado por um <a> que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
|
|
jest.mock("next/link", () => ({
|
|
__esModule: true,
|
|
default: ({
|
|
children,
|
|
prefetch,
|
|
...props
|
|
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
|
string,
|
|
unknown
|
|
>) => (
|
|
<a data-prefetch={String(prefetch)} {...props}>
|
|
{children}
|
|
</a>
|
|
),
|
|
}));
|
|
|
|
const clients = [
|
|
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
|
|
{ id: "lavacao-047", name: "Lavação 047" },
|
|
{ id: "sc-plus", name: "SC Plus Aquicultura" },
|
|
{ id: "as-servicos", name: "AS Serviços" },
|
|
];
|
|
|
|
describe("Hero", () => {
|
|
beforeEach(() => {
|
|
jest.spyOn(window, "open").mockImplementation(() => null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
it("sends the main button to the contact form, keeps 'Ver projetos entregues' secondary and opens WhatsApp from a quiet link after them", () => {
|
|
render(<Hero />);
|
|
|
|
const contactLink = screen.getByRole("link", {
|
|
name: /falar sobre meu projeto/i,
|
|
});
|
|
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
|
|
expect(contactLink).toHaveAttribute("href", "/#contact-form");
|
|
expect(contactLink).not.toHaveAttribute("target");
|
|
// o botão primário é o único com o fundo limão (e o bloco cor de papel no ponteiro); o secundário é contorno
|
|
expect(contactLink.className).toContain("bg-[var(--slash)]");
|
|
expect(contactLink).toHaveClass("cv-fill");
|
|
expect(contactLink.className).not.toMatch(/hover:bg-/);
|
|
const projects = screen.getByRole("link", { name: /ver projetos/i });
|
|
expect(projects).toHaveAttribute("href", "/cases");
|
|
expect(projects.className).not.toContain("bg-[var(--slash)]");
|
|
expect(projects).toHaveClass("cv-outline-dark");
|
|
expect(projects).not.toHaveAttribute("target");
|
|
|
|
const whatsApp = screen.getByRole("link", { name: "Chamar no WhatsApp" });
|
|
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
|
expect(whatsApp).toHaveAttribute("target", "_blank");
|
|
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
|
|
const text = new URL(whatsApp.getAttribute("href") ?? "").searchParams.get(
|
|
"text",
|
|
);
|
|
expect(text).toBe(
|
|
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
|
|
);
|
|
expect(whatsApp.className).not.toContain("bg-[var(--slash)]");
|
|
expect(whatsApp.querySelector("svg")).toHaveAttribute(
|
|
"aria-hidden",
|
|
"true",
|
|
);
|
|
// a ordem do Tab: os dois botões e depois o link
|
|
expect(
|
|
contactLink.compareDocumentPosition(whatsApp) &
|
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy();
|
|
expect(
|
|
projects.compareDocumentPosition(whatsApp) &
|
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("keeps the headline as one readable sentence despite the per-word animation", () => {
|
|
render(<Hero />);
|
|
|
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha.",
|
|
);
|
|
});
|
|
|
|
it("highlights only 'sob medida' in lime, kept on one line under one continuous marker block", () => {
|
|
render(<Hero />);
|
|
|
|
const heading = screen.getByRole("heading", { level: 1 });
|
|
const highlighted = Array.from(
|
|
heading.querySelectorAll<HTMLElement>(".hero-word"),
|
|
).filter((word) => word.className.includes("text-[var(--slash)]"));
|
|
expect(highlighted.map((word) => word.textContent)).toEqual([
|
|
"sob",
|
|
"medida",
|
|
]);
|
|
// um trecho só, que não quebra entre as duas palavras e leva o marcador; cada palavra mantém a sua entrada
|
|
const marks = heading.querySelectorAll(".hero-mark");
|
|
expect(marks).toHaveLength(1);
|
|
expect(marks[0]).toHaveClass("whitespace-nowrap");
|
|
expect(marks[0].textContent).toBe("sob medida");
|
|
for (const word of highlighted) expect(marks[0]).toContainElement(word);
|
|
});
|
|
|
|
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
|
|
render(<Hero />);
|
|
|
|
expect(screen.getByRole("link", { name: "LinkedIn" })).toHaveAttribute(
|
|
"href",
|
|
"https://www.linkedin.com/in/evandrodevbr/",
|
|
);
|
|
expect(screen.getByRole("link", { name: "Email" })).toHaveAttribute(
|
|
"href",
|
|
"mailto:[email protected]",
|
|
);
|
|
const linkedin = screen.getByRole("link", { name: "LinkedIn" });
|
|
expect(linkedin).toHaveAttribute("target", "_blank");
|
|
expect(linkedin).toHaveAttribute("rel", "noopener noreferrer");
|
|
expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull();
|
|
for (const link of screen.getAllByRole("link")) {
|
|
expect(link.getAttribute("href") ?? "").not.toMatch(/github\.com/);
|
|
}
|
|
});
|
|
|
|
it("states who the work is for and how the contact works", () => {
|
|
render(<Hero />);
|
|
|
|
expect(
|
|
screen.getByText(
|
|
"Do site institucional ao sistema que a sua equipe usa todo dia. Você fala com quem desenvolve.",
|
|
),
|
|
).toBeInTheDocument();
|
|
// o primeiro passo, logo abaixo dos botões
|
|
const step = screen.getByText(
|
|
"Começa com uma conversa para entender o seu caso. A proposta vem por escrito.",
|
|
);
|
|
expect(step.className).toContain("text-[15px]");
|
|
expect(
|
|
screen
|
|
.getByRole("link", { name: "Chamar no WhatsApp" })
|
|
.compareDocumentPosition(step) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy();
|
|
// o botão flutuante do WhatsApp observa este id: some enquanto o link do hero está à vista
|
|
expect(
|
|
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
|
|
).toHaveAttribute("id", "hero-contact-cta");
|
|
});
|
|
|
|
it("shows only the years of experience, with no repository numbers", () => {
|
|
render(<Hero stats={{ years: 5 }} />);
|
|
|
|
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
|
|
expect(screen.getByText("5")).toBeInTheDocument();
|
|
expect(screen.queryByRole("term")).toBeNull();
|
|
expect(screen.queryByText(/projetos desenvolvidos/i)).toBeNull();
|
|
expect(screen.queryByText(/públicos|privados|mais de/i)).toBeNull();
|
|
});
|
|
|
|
it("lists the delivered clients, each linking to its case", () => {
|
|
render(<Hero clients={clients} />);
|
|
|
|
expect(screen.getByText("Já entregue para:")).toBeInTheDocument();
|
|
for (const { id, name } of clients) {
|
|
expect(screen.getByRole("link", { name })).toHaveAttribute(
|
|
"href",
|
|
`/cases/${id}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("shows the block piece as a decorative poster: hidden from assistive tech, with an empty alt", () => {
|
|
const { container } = render(<Hero clients={clients} />);
|
|
|
|
const piece = container.querySelector(".hero-piece");
|
|
expect(piece).not.toBeNull();
|
|
expect(piece).toHaveAttribute("aria-hidden", "true");
|
|
const image = piece?.querySelector("img");
|
|
expect(image?.getAttribute("src")).toContain("hero-poster");
|
|
expect(image).toHaveAttribute("alt", "");
|
|
});
|
|
|
|
it("shows a wide card to the SEV case below the buttons on small screens", () => {
|
|
render(<Hero clients={clients} />);
|
|
|
|
const small = screen.getByTestId("hero-work-mobile");
|
|
expect(small).toHaveClass("lg:hidden");
|
|
expect(small).toHaveAttribute("href", "/cases/sev-modaintima");
|
|
expect(small).toHaveAccessibleName("Site da SEV Moda Íntima Ver case");
|
|
const buttons = screen.getByRole("link", { name: /ver projetos/i });
|
|
expect(
|
|
buttons.compareDocumentPosition(small) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("keeps the label above the headline in the display font, so the mono font is not downloaded before the LCP", () => {
|
|
const { container } = render(<Hero />);
|
|
|
|
const label = container.querySelector(".hero-kicker") as HTMLElement;
|
|
expect(label.textContent).toBe(
|
|
"Evandro Fonseca Junior · Engenharia de software",
|
|
);
|
|
expect(label.className).toContain("font-display");
|
|
expect(label.className).not.toContain("font-mono");
|
|
// sem caixa alta nem espaçamento largo
|
|
expect(label.className).not.toMatch(/uppercase|tracking-\[0\.1/);
|
|
});
|
|
|
|
it("shows only 'Engenharia de software' in the label below 1024 px, where it shares the line with the piece", () => {
|
|
render(<Hero />);
|
|
|
|
expect(screen.getByText(/Evandro Fonseca Junior/)).toHaveClass(
|
|
"max-lg:hidden",
|
|
);
|
|
expect(screen.getByText(/Engenharia de software/).className).not.toContain(
|
|
"hidden",
|
|
);
|
|
});
|
|
|
|
it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {
|
|
const { container } = render(
|
|
<Hero clients={clients} stats={{ years: 5 }} />,
|
|
);
|
|
|
|
expect(container.querySelector(".font-mono")).toBeNull();
|
|
});
|
|
|
|
it("pre-fetches 'Ver projetos entregues', the clients and the SEV card only when reached (hover, focus or touch), never by viewport", () => {
|
|
render(<Hero clients={clients} />);
|
|
|
|
const links = [
|
|
screen.getByRole("link", { name: /ver projetos/i }),
|
|
screen.getByRole("link", { name: "SEV Moda Íntima" }),
|
|
screen.getByTestId("hero-work-mobile"),
|
|
];
|
|
for (const link of links) {
|
|
expect(link).toHaveAttribute("data-prefetch", "false");
|
|
fireEvent.mouseEnter(link);
|
|
expect(link).toHaveAttribute("data-prefetch", "null");
|
|
}
|
|
});
|
|
|
|
it("writes the range of each dive caption (--from and --to, from hero-captions.ts) on its own item, in order", () => {
|
|
const { container } = render(<Hero clients={clients} />);
|
|
|
|
const items = Array.from(
|
|
container.querySelectorAll<HTMLElement>(".hero-captions li"),
|
|
);
|
|
expect(items).toHaveLength(HERO_CAPTIONS.length);
|
|
items.forEach((item, index) => {
|
|
const { title, text, from, to } = HERO_CAPTIONS[index];
|
|
expect(item).toHaveTextContent(`${title}${text}`);
|
|
expect(item.style.getPropertyValue("--from")).toBe(String(from));
|
|
expect(item.style.getPropertyValue("--to")).toBe(String(to));
|
|
});
|
|
});
|
|
});
|