Files
portifolio-evandrodevbr/app/servicos/[slug]/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

1017 lines
38 KiB
TypeScript

import { render, screen, within } from "@testing-library/react";
import { setNavigation } from "@/__mocks__/next/navigation";
import { SEO_IDS } from "@/lib/seo";
import {
buttonLabels,
caseStudies,
clientCasesForListing,
getRelatedCases,
getServicePage,
isClientCase,
serviceOffers,
servicePages,
siteUrl,
} from "@/lib/site-content";
import ServicesIndexPage from "../page";
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
beforeEach(() => {
setNavigation({ pathname: "/servicos" });
// a peça em loop do topo (OfferVideo) toca e pausa o vídeo, que o jsdom não implementa
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
async function renderService(slug: string) {
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
return render(ui);
}
function isBefore(first: Element, second: Element) {
return Boolean(
first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
);
}
/** Links "Ver case" dos cartões de "Onde já apliquei isso"; a capa do topo, que também abre o case, fica de fora. */
function caseCardLinks(container: HTMLElement) {
const block = container.querySelector<HTMLElement>("#casos-relacionados");
if (!block) throw new Error("Missing related cases block");
return within(block).getAllByRole("link", { name: /^Ver case / });
}
const service = getServicePage("consultoria-nextjs");
if (!service) throw new Error("Missing representative service");
const publicServiceSlugs = [
"criacao-de-sites",
"engenharia-de-software",
"atendimento-whatsapp-ia",
"nota-fiscal-reforma-tributaria",
"consultoria-nextjs",
"tauri-rust",
"integracao-hardware-software-iot",
"arquitetura-de-software",
] as const;
const publicServiceHrefs = publicServiceSlugs.map(
(slug) => `/servicos/${slug}`,
);
function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
const scripts = container.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]',
);
if (scripts.length === 0) throw new Error("Missing JSON-LD");
return Array.from(scripts).flatMap((script) => {
if (!script.textContent) throw new Error("Empty JSON-LD");
const value = JSON.parse(script.textContent) as Record<string, unknown>;
return Array.isArray(value["@graph"]) ? value["@graph"] : [value];
});
}
it("keeps static params and route metadata canonical", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(8);
expect(new Set(staticSlugs).size).toBe(8);
expect([...staticSlugs].sort()).toEqual([...publicServiceSlugs].sort());
const metadata = await generateMetadata({
params: Promise.resolve({ slug: service.slug }),
});
const url = `${siteUrl}/servicos/${service.slug}`;
expect(metadata).toMatchObject({
title: service.metaTitle,
description: service.metaDescription,
alternates: { canonical: url },
openGraph: {
title: service.metaTitle,
description: service.metaDescription,
url,
},
twitter: {
title: service.metaTitle,
description: service.metaDescription,
},
});
expect(metadata).not.toHaveProperty("keywords");
});
it("renders answer-first consulting, reciprocal case links and minimal schema", async () => {
const { container } = await renderService(service.slug);
const answer = screen.getByText(service.answer);
expect(answer).toBeVisible();
for (const item of [
...service.outcomes,
...service.examples,
...service.process,
]) {
expect(screen.getByText(item)).toBeVisible();
}
for (const detail of service.technicalDetails) {
expect(screen.getByText(detail)).not.toBeVisible();
}
const primaryCtas = screen.getAllByRole("link", {
name: buttonLabels.whatsapp,
});
expect(primaryCtas).toHaveLength(2);
for (const cta of primaryCtas) {
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
// o assunto da mensagem é o `whatsappSubject` do serviço, em minúsculas, e não o nome com maiúscula
expect(destination.searchParams.get("text")).toContain(
`Quero conversar sobre ${service.whatsappSubject}.`,
);
expect(destination.searchParams.get("text")).toContain(
"uma consultoria em Next.js",
);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
}
// o case da capa (SC Plus) é citado pela legenda dela, e não ganha cartão: os cartões são os demais cases relacionados
const heroId = service.heroCase?.id;
expect(heroId).toBe("sc-plus");
expect(
container.querySelector("section figcaption a[href='/cases/sc-plus']"),
).not.toBeNull();
const expectedCaseLinks = service.relatedCaseIds
.filter((id) => id !== heroId)
.map((id) => {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing related case ${id}`);
return {
name: `Ver case ${caseStudy.title}`,
href: `/cases/${id}`,
};
})
.sort((a, b) => a.href.localeCompare(b.href));
const internalCaseLinks = caseCardLinks(container);
expect(
internalCaseLinks
.map((link) => ({
name: link.textContent,
href: link.getAttribute("href"),
}))
.sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")),
).toEqual(expectedCaseLinks);
for (const internalLink of internalCaseLinks) {
const firstExternalLink = internalLink
.closest("article")
?.querySelector('a[target="_blank"]');
if (firstExternalLink) {
expect(
internalLink.compareDocumentPosition(firstExternalLink) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
}
}
const externalLinks = container.querySelectorAll('a[target="_blank"]');
expect(externalLinks.length).toBeGreaterThan(0);
for (const externalLink of externalLinks) {
expect(externalLink).toHaveAttribute(
"rel",
expect.stringMatching(/noreferrer/),
);
expect(externalLink.getAttribute("href")).not.toMatch(/github/i);
}
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(container.textContent).not.toMatch(
/Exemplos ilustrativos|não projetos já entregues|Sem promessas|indispon|Ver código/i,
);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
"Service",
"BreadcrumbList",
]);
expect(JSON.stringify(graph)).not.toMatch(
/Article|FAQPage|ProfessionalService/,
);
const serviceSchema = graph.find((node) => node["@type"] === "Service");
expect(serviceSchema).toMatchObject({
provider: { "@id": SEO_IDS.person },
});
const breadcrumb = graph.find(
(node) => node["@type"] === "BreadcrumbList",
) as {
itemListElement: Array<{
"@type": string;
position: number;
name: string;
item: string;
}>;
};
expect(breadcrumb.itemListElement).toEqual([
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Serviços",
item: `${siteUrl}/servicos`,
},
{
"@type": "ListItem",
position: 3,
name: service.navLabel,
item: `${siteUrl}/servicos/${service.slug}`,
},
]);
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Trilha de navegação",
});
expect(
within(visibleBreadcrumb)
.getAllByRole("link")
.map((link) => [link.textContent, link.getAttribute("href")]),
).toEqual([
["Início", "/"],
["Serviços", "/servicos"],
[service.navLabel, `/servicos/${service.slug}`],
]);
});
it("fills the hub hero with a mosaic of three client posters, never opening with the SEV, and states the location in one line", () => {
const { container } = render(<ServicesIndexPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
const posters = Array.from(hero?.querySelectorAll("img") ?? []);
expect(posters).toHaveLength(3);
// a ordem é a de /cases (a vitrine), sem a SEV, que lidera a lista mas não o mosaico
const mosaicCases = clientCasesForListing.filter(
({ id }) => id !== "sev-modaintima",
);
for (const [index, poster] of posters.entries()) {
expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain(
`/videos/${mosaicCases[index].id}/desktop.webp`,
);
expect(poster).toHaveAttribute("alt", "");
}
const posterSources = posters.map((poster) =>
decodeURIComponent(poster.getAttribute("src") ?? ""),
);
expect(posterSources[0]).toContain("/videos/lavacao-047/");
expect(posterSources.some((src) => src.includes("sev-modaintima"))).toBe(
false,
);
expect(new Set(posterSources).size).toBe(3);
// só o primeiro, o maior, carrega na hora e com prioridade alta
expect(posters[0]).toHaveAttribute("fetchpriority", "high");
expect(posters[1]).toHaveAttribute("loading", "lazy");
expect(hero).toHaveTextContent(
"Garuva e Joinville (SC) · atendimento remoto em todo o Brasil",
);
expect(container.textContent).not.toMatch(/perto de Garuva/);
// nenhum cliente do mosaico se repete mais abaixo: as ofertas citam os cases só por link, sem capa
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(0);
expect(container.querySelectorAll("img")).toHaveLength(3);
});
it("keeps hub cards and structured items aligned", () => {
const { container } = render(<ServicesIndexPage />);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
"CollectionPage",
"ItemList",
"BreadcrumbList",
]);
const itemList = graph[1] as {
itemListElement: Array<{ position: number; url: string; name: string }>;
};
expect(itemList.itemListElement.map(({ position }) => position)).toEqual([
1, 2, 3, 4, 5, 6, 7, 8,
]);
const structuredUrls = itemList.itemListElement.map(({ url }) => url);
expect(structuredUrls).toHaveLength(8);
expect(new Set(structuredUrls).size).toBe(8);
expect([...structuredUrls].sort()).toEqual(
publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(),
);
// as quatro ofertas que são serviços abrem pelo cartão da oferta; os outros quatro estão em "Outros serviços"
const offerLinks = serviceOffers
.filter(({ href }) => publicServiceHrefs.includes(href))
.map(({ name, href }) => ({
href,
link: screen.getByRole("link", { name: `Ver o serviço ${name}` }),
}));
expect(offerLinks).toHaveLength(4);
for (const { href, link } of offerLinks) {
expect(link).toHaveAttribute("href", href);
}
const otherLinks = itemList.itemListElement
.filter(({ url }) => !offerLinks.some(({ href }) => url.endsWith(href)))
.map(({ name }) => screen.getByRole("link", { name }));
expect(otherLinks).toHaveLength(4);
const visibleHrefs = [
...offerLinks.map(({ link }) => link.getAttribute("href")),
...otherLinks.map((link) => link.getAttribute("href")),
];
expect(visibleHrefs).toHaveLength(8);
expect(new Set(visibleHrefs).size).toBe(8);
expect([...visibleHrefs].sort()).toEqual([...publicServiceHrefs].sort());
expect(graph[2]).toMatchObject({
"@type": "BreadcrumbList",
itemListElement: [
{ position: 1, name: "Início", item: siteUrl },
{ position: 2, name: "Serviços", item: `${siteUrl}/servicos` },
],
});
});
it("puts the five offers right after the hero, each with its name, deliverables, proof and the right page, and 'Outros serviços' below", () => {
const { container } = render(<ServicesIndexPage />);
const offersHeading = screen.getByRole("heading", {
level: 2,
name: "Cinco jeitos de começar.",
});
const othersHeading = screen.getByRole("heading", {
level: 2,
name: "Outros serviços",
});
expect(
isBefore(screen.getByRole("heading", { level: 1 }), offersHeading),
).toBe(true);
expect(isBefore(offersHeading, othersHeading)).toBe(true);
const cards = Array.from(
container.querySelectorAll<HTMLElement>("section article"),
).slice(0, 5);
expect(cards).toHaveLength(5);
for (const [index, offer] of serviceOffers.entries()) {
const card = cards[index];
expect(
within(card).getByRole("heading", { level: 3, name: offer.name }),
).toBeVisible();
for (const item of offer.deliverables) {
expect(within(card).getByText(item)).toBeVisible();
}
expect(
within(card).getByRole("link", { name: `Ver o serviço ${offer.name}` }),
).toHaveAttribute("href", offer.href);
expect(within(card).getByText(offer.proof.label)).toBeVisible();
}
// a prova: Lavação 047 e SEV na oferta do site, a SEV e a Apolo na do sistema, a página /ia na da revisão; o atendimento
// e a nota fiscal não têm case e só levam à página do serviço
expect(
within(cards[0]).getByRole("link", { name: "Ver case Lavação 047" }),
).toHaveAttribute("href", "/cases/lavacao-047");
expect(
within(cards[0]).getByRole("link", { name: "Ver case SEV Moda Íntima" }),
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(
within(cards[1]).getByRole("link", { name: "Ver case SEV Moda Íntima" }),
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(within(cards[1]).getByText(/Apolo Energia/)).toBeVisible();
for (const card of [cards[2], cards[3], cards[4]]) {
expect(within(card).queryAllByRole("link")).toHaveLength(1);
}
expect(
within(cards[3]).getByRole("link", { name: /^Ver o serviço/ }),
).toHaveAttribute("href", "/ia");
expect(
within(cards[2]).getByText(/^Ainda não há case público/),
).toBeVisible();
expect(
within(cards[4]).getByText(/^Ainda não há case público/),
).toBeVisible();
});
it("keeps the hub free of case covers, with the SEV only as a proof link in two offers, and without disclaimers", () => {
const { container } = render(<ServicesIndexPage />);
// a SEV aparece só como link de prova nas duas ofertas, nunca como capa ou no mosaico
const sevLinks = container.querySelectorAll(
`a[href="/cases/sev-modaintima"]`,
);
expect(sevLinks).toHaveLength(2);
for (const link of sevLinks) {
expect(link.querySelector("img")).toBeNull();
}
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(0);
expect(container.textContent).not.toMatch(
/sem promessas|Demonstração publicada|limites|indispon/i,
);
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
});
it("shows the client cases first, right after the hero, with the first cover in a frame", async () => {
const { container } = await renderService("criacao-de-sites");
const siteService = getServicePage("criacao-de-sites");
if (!siteService) throw new Error("Missing service");
const casesHeading = screen.getByRole("heading", {
level: 2,
name: "Onde já apliquei isso",
});
expect(
isBefore(screen.getByRole("heading", { level: 1 }), casesHeading),
).toBe(true);
expect(
isBefore(
casesHeading,
screen.getByRole("heading", { level: 2, name: "Para quem faz sentido" }),
),
).toBe(true);
// o case da capa (Lavação 047) é citado pela legenda dela; "Onde já apliquei isso" traz a SEV como segundo trabalho
const caseLinks = caseCardLinks(container);
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/sev-modaintima",
]);
// SC Plus e AS Serviços saem dali e vão para o bloco menor "Também faço sites institucionais"
const alsoHeading = screen.getByRole("heading", {
level: 2,
name: "Também faço sites institucionais",
});
expect(isBefore(casesHeading, alsoHeading)).toBe(true);
const alsoBlock = alsoHeading.closest("section") as HTMLElement;
expect(
within(alsoBlock)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual(["/cases/sc-plus", "/cases/as-servicos"]);
expect(
within(alsoBlock).getByText(siteService.caseRelevance["sc-plus"]),
).toBeVisible();
// sem capa no bloco menor
expect(alsoBlock.querySelector("img")).toBeNull();
expect(
getRelatedCases(siteService.relatedCaseIds).map(({ id }) => id),
).toEqual(["lavacao-047", "sev-modaintima", "sc-plus", "as-servicos"]);
// pôster do case de destaque (Lavação 047) numa moldura no topo, no lugar do fundo verde
const hero = container.querySelector("section");
const heroFrame = hero?.querySelector(".ds-bframe");
expect(heroFrame).not.toBeNull();
expect(heroFrame?.querySelector("img")).toHaveAttribute(
"alt",
"Captura de tela do site Lavação 047",
);
expect(hero?.querySelector("a[href='/cases/lavacao-047']")).not.toBeNull();
// a SEV tem cartão (segundo trabalho), mas não vira a capa do topo
expect(hero?.textContent).not.toMatch(/SEV Moda Íntima/);
expect(caseCardLinks(container)[0].closest("article")).toHaveTextContent(
siteService.caseRelevance["sev-modaintima"],
);
// a legenda da capa cita o case com a frase de relação e o botão "Ver case"
const caption = hero?.querySelector("figcaption");
expect(caption).toHaveTextContent("Lavação 047");
expect(caption).toHaveTextContent(siteService.caseRelevance["lavacao-047"]);
expect(
within(caption as HTMLElement).getByRole("link", {
name: "Ver case Lavação 047",
}),
).toHaveAttribute("href", "/cases/lavacao-047");
// a mesma capa não aparece duas vezes: o case de destaque não ganha cartão; a SEV mantém a dela e os do bloco menor não têm capa
expect(
container.querySelectorAll(
"img[alt='Captura de tela do site Lavação 047']",
),
).toHaveLength(1);
expect(
container.querySelectorAll(
"img[alt='Captura de tela do site SC Plus Aquicultura']",
),
).toHaveLength(0);
});
it("repeats on the two offer pages the 'O que você recebe' and the 'Primeiro passo' of the offer, from the same source as the home", async () => {
for (const [slug, offer] of [
["criacao-de-sites", serviceOffers[0]],
["engenharia-de-software", serviceOffers[1]],
] as const) {
const { container, unmount } = await renderService(slug);
const receives = screen.getByRole("heading", {
level: 2,
name: "O que você recebe",
});
const block = receives.closest("section") as HTMLElement;
expect(
within(block)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(offer.deliverables);
const firstStep = within(block).getByRole("heading", {
level: 2,
name: "Primeiro passo",
});
expect(isBefore(receives, firstStep)).toBe(true);
expect(within(block).getByText(offer.firstStep)).toBeVisible();
// vem depois da prova e antes de "Para quem faz sentido"
expect(
isBefore(
screen.getByRole("heading", {
level: 2,
name: "Onde já apliquei isso",
}),
receives,
),
).toBe(true);
expect(
isBefore(
receives,
screen.getByRole("heading", {
level: 2,
name: "Para quem faz sentido",
}),
),
).toBe(true);
// o bloco não traz botão de contato próprio: o do topo e o do fim bastam
expect(within(block).queryByRole("link")).toBeNull();
expect(container.textContent).not.toMatch(/\d+\s?dias|R\$/);
unmount();
}
});
it("keeps a single list of deliverables per page: the offer pages drop 'O que podemos entregar', the others keep it", async () => {
for (const slug of ["criacao-de-sites", "engenharia-de-software"]) {
const { unmount } = await renderService(slug);
expect(
screen.getByRole("heading", { level: 2, name: "O que você recebe" }),
).toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "O que podemos entregar" }),
).toBeNull();
// "Para quem faz sentido" continua, sozinho
expect(
screen.getByRole("heading", { level: 2, name: "Para quem faz sentido" }),
).toBeInTheDocument();
unmount();
}
for (const slug of [
"consultoria-nextjs",
"tauri-rust",
"integracao-hardware-software-iot",
"arquitetura-de-software",
]) {
const { unmount } = await renderService(slug);
expect(
screen.getByRole("heading", { level: 2, name: "O que podemos entregar" }),
).toBeInTheDocument();
unmount();
}
});
it("leaves 'O que você recebe' and 'Primeiro passo' out of the services that are not an offer", async () => {
for (const slug of [
"consultoria-nextjs",
"tauri-rust",
"integracao-hardware-software-iot",
"arquitetura-de-software",
]) {
const { unmount } = await renderService(slug);
expect(
screen.queryByRole("heading", { name: "O que você recebe" }),
).toBeNull();
expect(
screen.queryByRole("heading", { name: "Primeiro passo" }),
).toBeNull();
expect(
screen.queryByRole("heading", {
name: "Também faço sites institucionais",
}),
).toBeNull();
unmount();
}
});
it("opens the systems proof with the Apolo Energia block, as the résumé says it, and a subtitle that starts from the pain", async () => {
const { container } = await renderService("engenharia-de-software");
const proof = container.querySelector<HTMLElement>("#casos-relacionados");
if (!proof) throw new Error("Missing proof block");
// o bloco da Apolo vem no topo da prova, antes dos cartões de case
const note = within(proof).getByText(/Apolo Energia/);
expect(note).toHaveTextContent(
"aplicações web e mobile para rotinas internas, APIs e painéis de operação",
);
expect(note.textContent).toMatch(/uso interno/);
expect(isBefore(note, caseCardLinks(container)[0])).toBe(true);
// o subtítulo parte da dor: planilha e retrabalho
const subtitle = screen.getByText(/planilhas, mensagens e retrabalho/);
expect(isBefore(screen.getByRole("heading", { level: 1 }), subtitle)).toBe(
true,
);
});
it("says the offer name, not the old label, in the breadcrumb, the title and the WhatsApp message of every service", async () => {
for (const { slug, navLabel, whatsappSubject } of servicePages) {
const { unmount } = await renderService(slug);
const trail = within(
screen.getByRole("navigation", { name: "Trilha de navegação" }),
).getAllByRole("link");
expect(trail[trail.length - 1]).toHaveTextContent(navLabel);
for (const cta of screen.getAllByRole("link", {
name: buttonLabels.whatsapp,
})) {
const text = new URL(cta.getAttribute("href") ?? "").searchParams.get(
"text",
);
expect(text).toContain(`Quero conversar sobre ${whatsappSubject}.`);
expect(text).not.toMatch(
/Sites profissionais|Sites, sistemas e aplicativos/,
);
}
unmount();
}
});
it("opens the two offer services with the offer name as the h1, and leaves the search title alone", async () => {
for (const [slug, offer] of [
["criacao-de-sites", serviceOffers[0]],
["engenharia-de-software", serviceOffers[1]],
] as const) {
const { unmount } = await renderService(slug);
expect(
screen.getByRole("heading", { level: 1, name: offer.name }),
).toBeVisible();
const metadata = await generateMetadata({
params: Promise.resolve({ slug }),
});
expect(metadata.title).toBe(getServicePage(slug)?.metaTitle);
expect(metadata.title).not.toBe(offer.name);
unmount();
}
});
it("covers the systems service with the SEV on the phone, in a phone frame, once", async () => {
const { container } = await renderService("engenharia-de-software");
const hero = container.querySelector("section");
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
expect(hero?.querySelector(".ds-phone")).not.toBeNull();
expect(hero?.querySelector(".ds-bframe")).toBeNull();
expect(
hero
?.querySelector("a[href='/cases/sev-modaintima']")
?.querySelector(".ds-phone-screen"),
).not.toBeNull();
// a SEV não vira imagem de navegador nem ganha cartão: a legenda do celular a cita, com a frase de relação e "Ver case"
expect(container.querySelector("img[alt*='SEV']")).toBeNull();
const caption = hero?.querySelector("figcaption");
expect(caption).toHaveTextContent("SEV Moda Íntima");
expect(caption).toHaveTextContent(
getServicePage("engenharia-de-software")?.caseRelevance["sev-modaintima"] ??
"",
);
expect(
within(caption as HTMLElement).getByRole("link", {
name: "Ver case SEV Moda Íntima",
}),
).toHaveAttribute("href", "/cases/sev-modaintima");
// só o cartão da Lavação 047 traz capa de navegador
expect(
Array.from(container.querySelectorAll(".ds-bframe img")).map((image) =>
image.getAttribute("alt"),
),
).toEqual(["Captura de tela do site Lavação 047"]);
const links = caseCardLinks(container).map((link) =>
link.getAttribute("href"),
);
expect(links).toEqual(["/cases/lavacao-047"]);
});
it("covers the Next.js service with SC Plus, not the SEV", async () => {
const { container } = await renderService("consultoria-nextjs");
const heroFrame = container.querySelector("section .ds-bframe");
expect(heroFrame?.querySelector("img")).toHaveAttribute(
"alt",
"Captura de tela do site SC Plus Aquicultura",
);
expect(container.textContent).not.toMatch(/SEV Moda Íntima/);
});
it("shows no case, no frame and no 'similar projects' button on the diagnosis service, only the Apolo note and a flow diagram", async () => {
const { container } = await renderService("arquitetura-de-software");
// nenhum case de cliente é um diagnóstico: só a experiência na Apolo, em texto
expect(
screen.getByRole("heading", { level: 2, name: "Como eu trabalho nisso" }),
).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Onde já apliquei isso" }),
).toBeNull();
expect(screen.getByText(/Apolo Energia/)).toBeVisible();
expect(screen.queryByRole("link", { name: /^Ver case / })).toBeNull();
expect(
screen.queryByRole("link", { name: "Ver projetos parecidos" }),
).toBeNull();
expect(screen.queryByText(/SEV Moda Íntima/)).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
// no lugar da metade vazia do topo: o esquema de fluxo, como imagem com texto alternativo
const diagram = screen.getByRole("img", { name: /interface chama a API/ });
expect(diagram.closest("section")).toBe(container.querySelector("section"));
});
it("lists the Windows app as a 'Projeto próprio' on the desktop service, as a personal Windows app project", async () => {
const { container } = await renderService("tauri-rust");
expect(
screen.getByRole("link", { name: "Ver case MinGW GCC Installer" }),
).toHaveAttribute("href", "/cases/gcc-installer");
expect(
screen.getByText("MinGW GCC Installer · Projeto próprio"),
).toBeVisible();
expect(
screen.getByText(/projeto próprio de aplicativo para Windows/),
).toBeVisible();
expect(container.textContent).not.toMatch(/em Python e não em Tauri/);
expect(
screen.getByRole("link", { name: "Ver projetos parecidos" }),
).toHaveAttribute("href", "#casos-relacionados");
// sem captura gravada, não há moldura nem imagem: o topo mostra o esquema do aplicativo no computador
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(
screen.getByRole("img", {
name: /aplicativo no computador troca informações/,
}),
).toBeVisible();
// só projeto próprio, sem cliente: o título não fala de "onde já apliquei"
expect(
screen.getByRole("heading", { level: 2, name: "Como eu trabalho nisso" }),
).toBeVisible();
});
it("keeps the hero in two columns with a cover or a diagram, and in one lower column only when it has neither", async () => {
const diagrams = {
"tauri-rust": /aplicativo no computador/,
"integracao-hardware-software-iot": /dispositivo envia as leituras/,
"arquitetura-de-software": /interface chama a API/,
} as const;
for (const [slug, label] of Object.entries(diagrams)) {
const { container, unmount } = await renderService(slug);
const hero = container.querySelector("section");
expect(hero?.className).not.toContain("md:!pb-sp-12");
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
expect(
within(hero as HTMLElement).getByRole("img", { name: label }),
).toBeVisible();
unmount();
}
// sem capa e sem esquema, o topo volta a ser uma coluna só e mais baixa
const bare = getServicePage("arquitetura-de-software");
if (!bare?.diagram) throw new Error("Missing diagram");
const originalDiagram = bare.diagram;
delete bare.diagram;
try {
const { container, unmount } = await renderService(bare.slug);
const hero = container.querySelector("section");
expect(hero?.className).toContain("md:!pb-sp-12");
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
expect(screen.queryByRole("img")).toBeNull();
unmount();
} finally {
bare.diagram = originalDiagram;
}
const withMedia = await renderService("criacao-de-sites");
const hero = withMedia.container.querySelector("section");
expect(hero?.className).not.toContain("md:!pb-sp-12");
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
const cover = hero?.querySelector(".ds-bframe img");
// a largura real da coluna da direita, e não 100vw; é a imagem que abre a página
expect(cover?.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/);
expect(cover).toHaveAttribute("loading", "eager");
expect(cover).toHaveAttribute("fetchpriority", "high");
});
it("puts the location in one line, 'Garuva e Joinville (SC)', and spaces the list items", async () => {
const { container } = await renderService("consultoria-nextjs");
expect(container.textContent).toMatch(
/Garuva e Joinville \(SC\) · atendimento remoto em todo o Brasil/,
);
expect(container.textContent).not.toMatch(/a partir de Garuva/);
const lists = Array.from(container.querySelectorAll("ul.ds-list"));
expect(lists).toHaveLength(4);
for (const list of lists) {
expect(list).toHaveClass("space-y-sp-2");
}
});
it("labels the Twins Lab prototype as a laboratory project on the IoT service", async () => {
const { container } = await renderService("integracao-hardware-software-iot");
const caseLinks = caseCardLinks(container);
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/twins-lab",
]);
expect(screen.getByText("Twins Lab · Projeto próprio")).toBeVisible();
expect(screen.queryByText(/Protótipo de laboratório/)).toBeNull();
// sem captura gravada, não há moldura nem imagem
expect(container.querySelector(".ds-bframe")).toBeNull();
});
it("labels only the laboratory projects as 'Projeto próprio'", async () => {
for (const slug of ["criacao-de-sites", "integracao-hardware-software-iot"]) {
const { unmount } = await renderService(slug);
const related = getRelatedCases(getServicePage(slug)?.relatedCaseIds ?? []);
expect(related.length).toBeGreaterThan(0);
const labels = screen.queryAllByText(/· Projeto próprio$/);
expect(labels).toHaveLength(related.filter((c) => !isClientCase(c)).length);
unmount();
}
});
it("says in one sentence, per case, why it matters to the service", async () => {
for (const {
slug,
relatedCaseIds,
caseRelevance,
heroCase,
} of servicePages) {
if (relatedCaseIds.length === 0) continue;
const { container, unmount } = await renderService(slug);
for (const id of relatedCaseIds) {
const sentence = caseRelevance[id];
expect(sentence).toBeTruthy();
const link = screen.getByRole("link", {
name: `Ver case ${caseStudies.find((c) => c.id === id)?.title}`,
});
if (id === heroCase?.id) {
// o case da capa: a frase está na legenda dela, junto do botão, e não num cartão
const caption = link.closest("figcaption");
expect(caption).not.toBeNull();
expect(caption).toHaveTextContent(sentence);
expect(
container.querySelectorAll(`article a[href="/cases/${id}"]`),
).toHaveLength(0);
} else {
// o cartão de "Onde já apliquei isso" ou a linha do bloco menor "Também faço sites institucionais"
const card = screen.getByText(sentence).closest("article, li");
expect(card).toContainElement(link);
}
}
unmount();
}
});
it("cites the Apolo Energia experience in text on the IoT and architecture pages, with no link", async () => {
for (const slug of [
"integracao-hardware-software-iot",
"arquitetura-de-software",
]) {
const note = getServicePage(slug)?.experienceNote ?? "";
const { container, unmount } = await renderService(slug);
const paragraph = screen.getByText(/Apolo Energia/);
expect(paragraph).toBeVisible();
expect(paragraph.textContent).toContain(note);
expect(paragraph.textContent).toMatch(/Desde 2022/);
expect(paragraph.closest("a")).toBeNull();
expect(paragraph.querySelector("a")).toBeNull();
// dentro do bloco de prova ("Como eu trabalho nisso": sem case de cliente), antes dos cards
const block = screen.getByRole("heading", {
name: "Como eu trabalho nisso",
});
expect(isBefore(block, paragraph)).toBe(true);
expect(
isBefore(
paragraph,
container.querySelector("article.ds-card") as Element,
),
).toBe(true);
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs.some((href) => /apolo/i.test(href))).toBe(false);
unmount();
}
});
it("does not mention Apolo Energia on the other service pages (the systems page cites it, tested above)", async () => {
for (const slug of ["criacao-de-sites", "consultoria-nextjs", "tauri-rust"]) {
const { container, unmount } = await renderService(slug);
expect(container.textContent).not.toMatch(/Apolo/);
unmount();
}
});
it("puts the FAQ before the next step and ends every service page with the same contact button", async () => {
for (const { slug, faq } of servicePages) {
const { container, unmount } = await renderService(slug);
const nextStep = screen.getByRole("heading", {
level: 2,
name: "Conte o que você precisa resolver.",
});
if (faq?.length) {
const faqHeading = screen.getByRole("heading", {
level: 2,
name: "Perguntas frequentes",
});
expect(isBefore(faqHeading, nextStep)).toBe(true);
}
// o botão de contato vem depois das dúvidas: o último elemento da página é a seção do próximo passo
const last = container.querySelector("main")?.lastElementChild;
expect(last).toContainElement(nextStep);
const cta = within(last as HTMLElement).getByRole("link", {
name: buttonLabels.whatsapp,
});
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
"https://wa.me",
);
// as perguntas são as linhas do acordeão (o mesmo desenho da home), todas antes do botão
for (const question of faq ?? []) {
expect(isBefore(screen.getByText(question.question), cta)).toBe(true);
}
// um rótulo só para o contato: sem "no WhatsApp" colado nem texto por serviço
expect(
screen
.getAllByRole("link", { name: buttonLabels.whatsapp })
.every((link) => link.textContent === buttonLabels.whatsapp),
).toBe(true);
expect(container.textContent).not.toMatch(/diretamente/i);
unmount();
}
});
it("renders the offer block only when some field is filled in", async () => {
const empty = await renderService("criacao-de-sites");
expect(
screen.queryByRole("heading", { name: "Investimento e prazo" }),
).toBeNull();
empty.unmount();
const offerService = getServicePage("criacao-de-sites");
if (!offerService) throw new Error("Missing service");
offerService.typicalTimeline = "Texto de prazo de teste";
try {
await renderService("criacao-de-sites");
expect(
screen.getByRole("heading", { level: 2, name: "Investimento e prazo" }),
).toBeVisible();
expect(screen.getByText("Prazo típico")).toBeVisible();
expect(screen.getByText("Texto de prazo de teste")).toBeVisible();
expect(screen.queryByText("A partir de")).toBeNull();
expect(screen.queryByText("Garantia")).toBeNull();
} finally {
delete offerService.typicalTimeline;
}
});
it("keeps related case ids resolving to real cases", () => {
for (const id of service.relatedCaseIds) {
expect(caseStudies.some((item) => item.id === id)).toBe(true);
}
});
it("shows the official dates or rules with a link to each source, after 'O que você recebe', only where the service has them", async () => {
for (const slug of [
"atendimento-whatsapp-ia",
"nota-fiscal-reforma-tributaria",
]) {
const page = getServicePage(slug);
if (!page?.official) throw new Error(`Missing official block of ${slug}`);
const { unmount } = await renderService(slug);
const heading = screen.getByRole("heading", {
level: 2,
name: page.official.title,
});
expect(
isBefore(
screen.getByRole("heading", { level: 2, name: "O que você recebe" }),
heading,
),
).toBe(true);
for (const item of page.official.items) {
expect(screen.getByText(item)).toBeVisible();
}
for (const { label, href } of page.official.links) {
const link = screen.getByRole("link", { name: label });
expect(link).toHaveAttribute("href", href);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
}
unmount();
}
await renderService("consultoria-nextjs");
expect(document.getElementById("fonte-oficial")).toBeNull();
});