Files
portifolio-evandrodevbr/app/ia/page.test.tsx
T
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

342 lines
13 KiB
TypeScript

import { render, screen, within } from "@testing-library/react";
import { offerLabels } from "@/lib/offer-labels";
import { buttonLabels } from "@/lib/site-content";
import { IA_PAGE_LABEL } from "@/lib/site-navigation";
import AiPage, { metadata } from "./page";
it("uses a short search title: up to 44 characters, and 60 with the layout suffix", () => {
// o template de app/layout.tsx acrescenta " | Evandro.dev.br"
const suffix = " | Evandro.dev.br";
expect(metadata.title).toBe("Revisão de sistema e de app feito com IA");
expect(String(metadata.title).length).toBeLessThanOrEqual(44);
expect(`${metadata.title}${suffix}`.length).toBeLessThanOrEqual(60);
expect(metadata).toMatchObject({
description: expect.any(String),
alternates: { canonical: "https://evandro.dev.br/ia" },
});
// como nas outras páginas, o título da prévia de compartilhamento é o mesmo da busca, sem o sufixo
expect(metadata.openGraph?.title).toBe(
"Revisão de sistema e de app feito com IA",
);
expect(metadata.twitter?.title).toBe(
"Revisão de sistema e de app feito com IA",
);
});
it("uses no lime outside the contact actions (the quote on the paper background is ink)", () => {
const { container } = render(<AiPage />);
expect(
container.querySelector('[class*="border-[var(--slash)]"]'),
).toBeNull();
});
it("keeps the full AI content, with the cited incidents summarized only here", () => {
render(<AiPage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Seu app feito com IA está pronto para receber clientes? Eu testo antes deles.",
);
for (const heading of [
"Se a IA cria um sistema, por que contratar um desenvolvedor?",
"O que casos documentados ensinam",
"E a LGPD?",
]) {
expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument();
}
for (const incident of [/Lovable:/, /Replit:/, /Base44:/]) {
expect(screen.getByRole("heading", { name: incident })).toBeInTheDocument();
}
expect(
screen.getByRole("link", { name: "Reportagem da Semafor (em inglês)" }),
).toHaveAttribute("rel", "noopener noreferrer");
});
it("offers WhatsApp conversations about reviewing an AI-built system, each one named 'Chamar no WhatsApp'", () => {
render(<AiPage />);
const links = screen
.getAllByRole("link")
.filter((link) => link.getAttribute("href")?.startsWith("https://wa.me/"));
expect(links.map((link) => link.textContent)).toEqual([
buttonLabels.whatsapp,
buttonLabels.whatsapp,
]);
for (const link of links) {
const text = new URL(link.getAttribute("href") ?? "").searchParams.get(
"text",
);
// a mensagem tem de formar uma frase: "Quero conversar sobre a revisão de um aplicativo feito com IA."
expect(text).toContain(
"Quero conversar sobre a revisão de um aplicativo feito com IA.",
);
}
});
it("has breadcrumb navigation back to the home", () => {
render(<AiPage />);
const breadcrumb = screen.getByRole("navigation", {
name: "Trilha de navegação",
});
expect(
within(breadcrumb).getByRole("link", { name: "Início" }),
).toHaveAttribute("href", "/");
expect(within(breadcrumb).getByText(IA_PAGE_LABEL)).toHaveAttribute(
"aria-current",
"page",
);
});
it("presents the review as an offer: what the person receives, with no price or deadline", () => {
const { container } = render(<AiPage />);
const offer = screen.getByRole("heading", {
name: "O que você recebe na revisão",
});
const section = offer.closest("section") as HTMLElement;
const items = within(section)
.getAllByRole("listitem")
.map((item) => item.querySelector("h3")?.textContent);
expect(items).toEqual([
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
]);
expect(container.textContent).not.toMatch(/R\$|\d+ dias|em até \d+/);
});
it("shows 'Como funciona em 3 passos' in a card on the right half of the hero, next to the headline, instead of repeating the deliverables", () => {
render(<AiPage />);
const card = screen.getByTestId("ia-steps-card");
expect(
within(card).getByText("Como funciona em 3 passos"),
).toBeInTheDocument();
const steps = within(card).getAllByRole("listitem");
expect(steps).toHaveLength(3);
expect(
steps.map((item) => item.querySelector("strong")?.textContent),
).toEqual([
"Conversa pelo WhatsApp.",
"Teste e avaliação.",
"Relatório e orçamento.",
]);
// a conversa vem primeiro e repete o aviso da página: nada de senhas nem acessos por mensagem
expect(steps[0]).toHaveTextContent(
"Não envie senhas nem acessos por mensagem.",
);
// o cartão divide a grade do hero com o título (metade direita no desktop) e não cria uma região de página
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
expect(hero).toContainElement(card);
expect(card.parentElement).toHaveClass(
"lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]",
);
expect(screen.queryByRole("complementary")).toBeNull();
// os quatro entregáveis ficam só na seção "O que você recebe na revisão": o hero não os repete
expect(within(card).queryByText("O que você recebe")).toBeNull();
for (const item of [
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
]) {
expect(within(card).queryByText(item)).toBeNull();
}
expect(
screen.getAllByRole("heading", { name: /O que você recebe/ }),
).toHaveLength(1);
});
it("keeps the in-page secondary link and brings back a secondary link to the cases in the hero, both as text links", () => {
render(<AiPage />);
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
const secondary = within(hero as HTMLElement).getByRole("link", {
name: "Ver como a revisão funciona",
});
expect(secondary).toHaveAttribute("href", "#ia-receber");
expect(document.querySelector(`section[id="ia-receber"]`)).not.toBeNull();
const cases = within(hero as HTMLElement).getByRole("link", {
name: "Ver projetos entregues",
});
expect(cases).toHaveAttribute("href", "/cases");
// os dois são links de texto; o único botão cheio do hero é o do WhatsApp
expect(cases.className).toBe(secondary.className);
const hrefs = within(hero as HTMLElement)
.getAllByRole("link")
.map((link) => link.getAttribute("href"));
expect(hrefs.filter((href) => href?.startsWith("/cases"))).toEqual([
"/cases",
]);
});
it("shows the report model with three illustrative findings (flaw, severity, suggested fix), labelled as examples and with no client name", () => {
const { container } = render(<AiPage />);
const heading = screen.getByRole("heading", {
level: 2,
name: "Modelo do relatório",
});
const section = heading.closest("section") as HTMLElement;
expect(section).toHaveAttribute("id", "ia-modelo");
expect(section).toHaveTextContent("exemplos ilustrativos");
expect(section).toHaveTextContent("não são de nenhum cliente");
const findings = within(section).getAllByRole("article");
expect(findings).toHaveLength(3);
for (const [index, finding] of findings.entries()) {
expect(finding).toHaveTextContent(`Exemplo ilustrativo 0${index + 1}`);
expect(
Array.from(finding.querySelectorAll("dt")).map(
(term) => term.textContent,
),
).toEqual(["Falha", "Gravidade", "Correção sugerida"]);
for (const definition of finding.querySelectorAll("dd")) {
expect(definition.textContent?.trim()).not.toBe("");
}
}
// a gravidade é uma palavra só, de uma escala simples
expect(
findings.map((finding) => finding.querySelectorAll("dd")[1].textContent),
).toEqual(["Alta", "Alta", "Média"]);
// sem nome de cliente do portfólio, sem preço nem prazo
expect(section.textContent).not.toMatch(
/SEV|Lavação|SC Plus|AS Serviços|Apolo|R\$|\d+ dias|em até \d+/,
);
// o modelo vem depois do que se recebe e antes de "por que contratar"
const receive = container.querySelector("#ia-receber") as HTMLElement;
expect(
receive.compareDocumentPosition(section) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
const hire = screen
.getByRole("heading", {
name: "Se a IA cria um sistema, por que contratar um desenvolvedor?",
})
.closest("section") as HTMLElement;
expect(
section.compareDocumentPosition(hire) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("keeps the incident news collapsed in a <details>, closed by default", () => {
const { container } = render(<AiPage />);
const heading = screen.getByRole("heading", {
name: "O que casos documentados ensinam",
});
const details = heading.closest("details") as HTMLDetailsElement;
expect(details).not.toBeNull();
expect(details.open).toBe(false);
expect(heading.closest("summary")).not.toBeNull();
for (const incident of [/Lovable:/, /Replit:/, /Base44:/]) {
expect(within(details).getByText(incident)).toBeInTheDocument();
}
// só os incidentes ficam recolhidos: a oferta e a LGPD ficam à vista
expect(container.querySelectorAll("details")).toHaveLength(1);
expect(
screen
.getByRole("heading", { name: "O que você recebe na revisão" })
.closest("details"),
).toBeNull();
});
it("lists the five incidents from 2025 and 2026, with the new sources opening in a new tab", () => {
render(<AiPage />);
for (const title of [
"Lovable: dados expostos em aplicações criadas na plataforma",
"Replit: um agente apagou dados de uma aplicação",
"Base44: aplicações privadas com falha de acesso",
"Lovable, 2026: conversas e código de projetos públicos ficaram abertos",
"RedAccess, 2026: aplicações internas abertas na internet",
]) {
expect(screen.getByRole("heading", { name: title })).toBeInTheDocument();
}
expect(
screen.getByText(/São episódios de 2025 e 2026, não avaliações/),
).toBeInTheDocument();
for (const [name, href] of [
[
"Relato da Lovable (22/04/2026, em inglês)",
"https://lovable.dev/blog/our-response-to-the-april-2026-incident",
],
[
"Reportagem do Security Boulevard (07/05/2026, em inglês)",
"https://securityboulevard.com/2026/05/thousands-of-vibe-coded-apps-exposing-corporate-personal-data-redaccess/",
],
[
"Reportagem do Axios (em inglês)",
"https://www.axios.com/2026/05/07/loveable-replit-vibe-coding-privacy",
],
]) {
const link = screen.getByRole("link", { name });
expect(link).toHaveAttribute("href", href);
expect(link).toHaveAttribute("target", "_blank");
expect(link.getAttribute("rel")).toContain("noopener");
}
});
it("adds the incident deadline to the LGPD card and a third card on who answers for what the AI writes", () => {
render(<AiPage />);
const lgpd = screen
.getByRole("heading", { name: "E a LGPD?" })
.closest("article") as HTMLElement;
expect(lgpd).toHaveTextContent(
"a empresa precisa comunicar a ANPD e as pessoas afetadas em três dias úteis depois de saber do fato; agentes de pequeno porte têm o prazo em dobro.",
);
const card = screen
.getByRole("heading", { name: "Quem responde pelo que a IA escreve?" })
.closest("article") as HTMLElement;
expect(card).toHaveClass("ds-card");
expect(card).toHaveTextContent("quem assina responde pelo que entrega.");
// os três cartões dividem a mesma grade
expect(card.parentElement).toBe(lgpd.parentElement);
expect(card.parentElement?.querySelectorAll("article.ds-card")).toHaveLength(
3,
);
expect(card.parentElement).toHaveClass("lg:grid-cols-3");
// alinhados pelo topo: o .ds-copy (flex-grow: 1) não estica o cartão mais curto com um buraco no meio
expect(card.parentElement).toHaveClass("items-start");
for (const [scope, name, href] of [
[
lgpd,
"ANPD: Resolução nº 15/2024, arts. 6º e 9º",
"https://www.in.gov.br/en/web/dou/-/resolucao-cd/anpd-n-15-de-24-de-abril-de-2024-556243024",
],
[
card,
"TST: notícia de 26/03/2026",
"https://www.tst.jus.br/-/empresa-e-advogado-sao-multados-por-uso-de-ia-com-decisoes-falsas",
],
] as const) {
const link = within(scope).getByRole("link", { name });
expect(link).toHaveAttribute("href", href);
expect(link).toHaveAttribute("target", "_blank");
expect(link.getAttribute("rel")).toContain("noopener");
}
});
it("names the page with its own label in the trail and the structured data (the one in the menu and the footer), and keeps the offer name for the offer", () => {
const { container } = render(<AiPage />);
const script = container.querySelector('script[type="application/ld+json"]');
const data = JSON.parse(script?.textContent ?? "{}");
const breadcrumb = data["@graph"].find(
(node: { "@type": string }) => node["@type"] === "BreadcrumbList",
);
expect(
breadcrumb.itemListElement.map(({ name }: { name: string }) => name),
).toEqual(["Início", "Revisão de app com IA"]);
expect(IA_PAGE_LABEL).toBe("Revisão de app com IA");
expect(offerLabels.revisao.name).toBe("Revisão de sistema que já existe");
// sem rótulo sobre o título: a trilha logo acima já nomeia a página
expect(
container.querySelector("section")?.querySelector(".ds-eyebrow"),
).toBeNull();
});