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.
1017 lines
38 KiB
TypeScript
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();
|
|
});
|