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" }); }); 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("#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", "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[] { const scripts = container.querySelectorAll( '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; 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(6); expect(new Set(staticSlugs).size).toBe(6); 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.contact, }); 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(); 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(); 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, ]); const structuredUrls = itemList.itemListElement.map(({ url }) => url); expect(structuredUrls).toHaveLength(6); expect(new Set(structuredUrls).size).toBe(6); expect([...structuredUrls].sort()).toEqual( publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(), ); // as duas 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(2); 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(6); expect(new Set(visibleHrefs).size).toBe(6); 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 three offers right after the hero, each with its name, deliverables, proof and the right page, and 'Outros serviços' below", () => { const { container } = render(); const offersHeading = screen.getByRole("heading", { level: 2, name: "Três 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("section article"), ).slice(0, 3); expect(cards).toHaveLength(3); 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 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(); expect(within(cards[2]).queryAllByRole("link")).toHaveLength(1); expect( within(cards[2]).getByRole("link", { name: /^Ver o serviço/ }), ).toHaveAttribute("href", "/ia"); }); 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(); // 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("#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.contact, })) { 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.contact, }); 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.contact }) .every((link) => link.textContent === buttonLabels.contact), ).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); } });