From d356c364e0538ad19e6ba74bceb6811de6a43aab Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 14 Oct 2026 22:54:19 -0300 Subject: [PATCH] =?UTF-8?q?fix(cases):=20um=20s=C3=B3=20convite=20ao=20Wha?= =?UTF-8?q?tsApp=20no=20fim=20do=20case=20e=20testes=20alinhados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[slug]/opengraph-image.paths.test.tsx | 6 - app/cases/[slug]/page.facts.paths.test.tsx | 9 +- app/cases/[slug]/page.paths.test.tsx | 17 ++- app/cases/[slug]/page.test.tsx | 109 +++++++++++------- .../CaseCapabilitiesSection.paths.test.tsx | 107 +++-------------- .../_components/CaseCapabilitiesSection.tsx | 59 +++------- app/cases/page.paths.test.tsx | 14 ++- 7 files changed, 124 insertions(+), 197 deletions(-) diff --git a/app/cases/[slug]/opengraph-image.paths.test.tsx b/app/cases/[slug]/opengraph-image.paths.test.tsx index cb5d339..e5c690c 100644 --- a/app/cases/[slug]/opengraph-image.paths.test.tsx +++ b/app/cases/[slug]/opengraph-image.paths.test.tsx @@ -45,12 +45,6 @@ afterEach(() => { mockedReadProjectOgImage.mockImplementation(actualReadProjectOgImage); }); -jest.mock("next/navigation", () => ({ - notFound: jest.fn(() => { - throw new Error("NEXT_NOT_FOUND"); - }), -})); - jest.mock("next/og", () => ({ ImageResponse: jest.fn() })); const mockedImageResponse = jest.mocked(ImageResponse); diff --git a/app/cases/[slug]/page.facts.paths.test.tsx b/app/cases/[slug]/page.facts.paths.test.tsx index 93966fe..98337e4 100644 --- a/app/cases/[slug]/page.facts.paths.test.tsx +++ b/app/cases/[slug]/page.facts.paths.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import { setNavigation } from "@/__mocks__/next/navigation"; import { caseStudies } from "@/lib/site-content"; import CasePage from "./page"; @@ -7,11 +8,9 @@ import CasePage from "./page"; * O teste principal cobre a lista ausente (`undefined`) e a lista preenchida; aqui, a lista vazia (`[]`). */ -// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home -jest.mock("next/navigation", () => ({ - notFound: jest.fn(), - usePathname: () => "/cases", -})); +beforeEach(() => { + setNavigation({ pathname: "/cases" }); +}); function getCase(id: string) { const caseStudy = caseStudies.find((item) => item.id === id); diff --git a/app/cases/[slug]/page.paths.test.tsx b/app/cases/[slug]/page.paths.test.tsx index 809e526..0f44a9a 100644 --- a/app/cases/[slug]/page.paths.test.tsx +++ b/app/cases/[slug]/page.paths.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; import { notFound } from "next/navigation"; +import { setNavigation } from "@/__mocks__/next/navigation"; import { caseStudies, getRelatedServices } from "@/lib/site-content"; import { getCaseMedia } from "../_components/case-media"; import CasePage, { generateMetadata } from "./page"; @@ -10,13 +11,9 @@ import CasePage, { generateMetadata } from "./page"; * demonstração sem captura, resultado vazio e case sem serviço relacionado. */ -// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home -jest.mock("next/navigation", () => ({ - notFound: jest.fn(() => { - throw new Error("NEXT_NOT_FOUND"); - }), - usePathname: () => "/cases", -})); +beforeEach(() => { + setNavigation({ pathname: "/cases" }); +}); jest.mock("../_components/case-media", () => { const actual = jest.requireActual("../_components/case-media"); @@ -127,12 +124,12 @@ describe("CasePage: caminhos que o teste principal não cobre", () => { expect(screen.getByText(/Projeto de laboratório/)).toBeVisible(); expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull(); expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull(); - // o NutriScan tem "Como ficou": o botão de contato vem depois dele e de novo no fecho, com o mesmo rótulo + // o NutriScan tem "Como ficou", mas o botão de contato é um só, o do fecho (sem dois seguidos) const contacts = screen.getAllByRole("link", { name: "Falar sobre meu projeto", }); - expect(contacts).toHaveLength(2); - for (const contact of contacts) expect(contact).toBeVisible(); + expect(contacts).toHaveLength(1); + expect(contacts[0]).toBeVisible(); expect( screen.getByRole("link", { name: "Ver outros cases" }), ).toHaveAttribute("href", "/cases"); diff --git a/app/cases/[slug]/page.test.tsx b/app/cases/[slug]/page.test.tsx index f09feb1..6ae92cb 100644 --- a/app/cases/[slug]/page.test.tsx +++ b/app/cases/[slug]/page.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; +import { setNavigation } from "@/__mocks__/next/navigation"; import { buttonLabels, caseStudies, @@ -10,11 +11,9 @@ import { similarProjectSubject } from "@/lib/whatsapp"; import CasesPage from "../page"; import CasePage, { generateMetadata, generateStaticParams } from "./page"; -// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home -jest.mock("next/navigation", () => ({ - notFound: jest.fn(), - usePathname: () => "/cases", -})); +beforeEach(() => { + setNavigation({ pathname: "/cases" }); +}); const clientCaseIds = [ "sev-modaintima", @@ -63,12 +62,9 @@ function readJsonLdEntities(container: HTMLElement): Record[] { /** * Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo * ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho: - * `similarProjectSubject(título)`. Com "Como ficou" na página, o botão aparece duas vezes: logo depois dele e no fecho. + * `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Falar sobre meu projeto" seguidos. */ -function expectContactAndNoDeadLinks( - caseStudy: { id: string; title: string }, - expectedButtons = 1, -) { +function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) { const hrefs = screen .getAllByRole("link") .map((link) => link.getAttribute("href") ?? ""); @@ -76,7 +72,7 @@ function expectContactAndNoDeadLinks( expect(hrefs.some((href) => /github/i.test(href))).toBe(false); const ctas = screen.getAllByRole("link", { name: buttonLabels.contact }); - expect(ctas).toHaveLength(expectedButtons); + expect(ctas).toHaveLength(1); for (const cta of ctas) { const destination = new URL(cta.getAttribute("href") ?? ""); expect(destination.origin).toBe("https://wa.me"); @@ -171,11 +167,17 @@ it("renders a client case in the sales order, with media, contact and collapsed screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }), ).toHaveAttribute("href", caseStudy.demoUrl); - // o botão de contato se repete logo depois de "Como ficou", dentro da seção das capacidades - expectContactAndNoDeadLinks(caseStudy, 2); + // um botão de contato só, no fecho: as capacidades (logo depois de "Como ficou") não repetem o botão + expectContactAndNoDeadLinks(caseStudy); const capabilitiesSection = sectionHeadings[4].closest("section"); - expect(capabilitiesSection).toContainElement( - within(capabilitiesSection as HTMLElement).getByRole("link", { + expect( + within(capabilitiesSection as HTMLElement).queryByRole("link", { + name: buttonLabels.contact, + }), + ).toBeNull(); + const closingSection = sectionHeadings[5].closest("section"); + expect(closingSection).toContainElement( + within(closingSection as HTMLElement).getByRole("link", { name: buttonLabels.contact, }), ); @@ -184,14 +186,6 @@ it("renders a client case in the sales order, with media, contact and collapsed within(capabilitiesSection as HTMLElement).getByText(capability), ).toBeVisible(); } - expect( - isBefore( - sectionHeadings[3], - within(capabilitiesSection as HTMLElement).getByRole("link", { - name: buttonLabels.contact, - }), - ), - ).toBe(true); // detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte const details = container.querySelector("details"); @@ -293,8 +287,8 @@ it("renders the two new client cases from the portfolio data", async () => { expect( screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }), ).toHaveAttribute("href", caseStudy.demoUrl); - // com "Como ficou" na página, o contato aparece logo depois dele e de novo no fecho - expectContactAndNoDeadLinks(caseStudy, 2); + // um botão de contato só, no fecho, mesmo com "Como ficou" na página + expectContactAndNoDeadLinks(caseStudy); unmount(); } }); @@ -336,9 +330,8 @@ it("turns the contact of a laboratory case into the 'Fale sobre o seu caso' invi expect( screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }), ).toBeVisible(); - // a repetição do botão só vem junto de "Como ficou" - const hasMedia = screen.queryByRole("heading", { name: "Como ficou" }); - expectContactAndNoDeadLinks(getCase(id), hasMedia ? 2 : 1); + // um botão só, com ou sem "Como ficou" (NutriScan tem) + expectContactAndNoDeadLinks(getCase(id)); // o laboratório não tem "o que o cliente passou a poder fazer" expect( screen.queryByRole("heading", { @@ -349,7 +342,7 @@ it("turns the contact of a laboratory case into the 'Fale sobre o seu caso' invi } }); -it("writes what the client can do now right after 'Como ficou', then repeats the contact button, for every client case", async () => { +it("writes what the client can do now right after 'Como ficou', with no contact button of its own, for every client case", async () => { for (const id of clientCaseIds) { const caseStudy = getCase(id); const { unmount } = await renderCase(id); @@ -371,11 +364,24 @@ it("writes what the client can do now right after 'Como ficou', then repeats the expect(items.length).toBeLessThanOrEqual(3); // sem número inventado expect(items.join(" ")).not.toMatch(/\d/); - const button = within(capabilitiesSection as HTMLElement).getByRole( - "link", - { name: buttonLabels.contact }, - ); - expect(isBefore(media, button)).toBe(true); + // o único botão de contato é o do fecho, depois da lista: não há dois "Falar sobre meu projeto" seguidos + expect( + within(capabilitiesSection as HTMLElement).queryByRole("link", { + name: buttonLabels.contact, + }), + ).toBeNull(); + const closingSection = screen + .getByRole("heading", { + level: 2, + name: "Quer um projeto parecido com este?", + }) + .closest("section"); + expect(capabilitiesSection?.nextElementSibling).toBe(closingSection); + expect( + within(closingSection as HTMLElement).getAllByRole("link", { + name: buttonLabels.contact, + }), + ).toHaveLength(1); unmount(); } @@ -463,16 +469,16 @@ it("shows at most two related services, as cards under the contact button", asyn expect( screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }), ).toBeNull(); - // o botão do fecho (o último) vem antes dos cartões - const contacts = screen.getAllByRole("link", { name: buttonLabels.contact }); - expect(isBefore(contacts[contacts.length - 1], cards[0])).toBe(true); + // o botão do fecho (o único) vem antes dos cartões + const contact = screen.getByRole("link", { name: buttonLabels.contact }); + expect(isBefore(contact, cards[0])).toBe(true); unmount(); - // sc-plus tem dois; a SEV e a AS Serviços, um cada (a SEV só aparece na página de sistemas) + // sc-plus e a SEV têm dois (a SEV liga à oferta de site e à de sistema); a AS Serviços, um for (const [id, expected] of [ ["sc-plus", 2], ["as-servicos", 1], - ["sev-modaintima", 1], + ["sev-modaintima", 2], ] as const) { const { unmount: unmountCase } = await renderCase(id); const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ }); @@ -482,11 +488,25 @@ it("shows at most two related services, as cards under the contact button", asyn .slice(0, 2) .map(({ slug }) => `/servicos/${slug}`), ); - expectContactAndNoDeadLinks(getCase(id), 2); + expectContactAndNoDeadLinks(getCase(id)); unmountCase(); } }); +it("links the SEV case to the site offer and to the system offer, each by the name of the offer", async () => { + await renderCase("sev-modaintima"); + + const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ }); + expect(links.map((link) => link.getAttribute("href"))).toEqual([ + "/servicos/criacao-de-sites", + "/servicos/engenharia-de-software", + ]); + expect(links.map((link) => link.textContent)).toEqual([ + "Ver o serviço: Site que traz pedidos pelo WhatsApp", + "Ver o serviço: Sistema para tirar a operação da planilha", + ]); +}); + it("writes the CreativeWork of a client case with creator, client and keywords", async () => { const caseStudy = getCase("lavacao-047"); const { container } = await renderCase(caseStudy.id); @@ -652,10 +672,15 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc expect(isBefore(clientHeading, labHeading)).toBe(true); expect(screen.getByText(/Projetos pessoais e de estudo/)).toBeVisible(); - // cases de cliente, com a SEV por último: capa (pôster) em moldura de navegador e botão "Ver case" em contorno + // cases de cliente, na ordem da vitrine (SEV primeiro): capa (pôster) em moldura de navegador e botão "Ver case" em contorno const listedIds = clientCasesForListing.map(({ id }) => id); expect([...listedIds].sort()).toEqual([...clientCaseIds].sort()); - expect(listedIds[listedIds.length - 1]).toBe("sev-modaintima"); + expect(listedIds).toEqual([ + "sev-modaintima", + "lavacao-047", + "sc-plus", + "as-servicos", + ]); const clientLinks = listedIds.map((id) => { const card = container .querySelector(`a[href="/cases/${id}"]`) diff --git a/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx b/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx index 21d42a8..c5e9517 100644 --- a/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx +++ b/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx @@ -1,46 +1,26 @@ import { render, screen } from "@testing-library/react"; -import { getShowcaseAssets } from "@/lib/project-showcase"; -import { buttonLabels } from "@/lib/site-content"; -import { similarProjectSubject } from "@/lib/whatsapp"; +import { setNavigation } from "@/__mocks__/next/navigation"; import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection"; -import { getCaseMedia } from "./case-media"; /** - * Testes de caminhos-base de CaseCapabilitiesSection: com itens e "Como ficou" (lista e botão), só com "Como ficou" - * (só o botão), só com itens (só a lista, sem repetir o botão que o fecho da página já traz) e sem nada (não aparece). - * A existência das capturas em disco é simulada em getCaseMedia. + * Testes de caminhos-base de CaseCapabilitiesSection: com itens (a lista sob o título do cliente ou do laboratório, sem + * botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Falar sobre meu projeto" + * seguidos) e sem itens (a seção não aparece, tenha o case site no ar ou captura ou não). */ -jest.mock("next/navigation", () => ({ usePathname: () => "/cases" })); -jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() })); - -const mockedGetCaseMedia = jest.mocked(getCaseMedia); +beforeEach(() => { + setNavigation({ pathname: "/cases" }); +}); const capabilities = [ "O dono muda os preços sem pedir a ninguém.", "O cliente pede pelo WhatsApp com a mensagem pronta.", ]; -const withSite = { - id: "projeto-teste", - title: "Projeto Teste", - demoUrl: "https://exemplo.com.br", -}; - -function messageOf(link: HTMLElement) { - return new URL(link.getAttribute("href") ?? "").searchParams.get("text"); -} - -beforeEach(() => { - mockedGetCaseMedia.mockReturnValue(undefined); -}); describe("CaseCapabilitiesSection", () => { - it("path: items and a site on the air -> the list under the client title, then the contact button", () => { - render( - , + it("path: items -> the list under the client title, with no contact button of its own", () => { + const { container } = render( + , ); expect( @@ -52,25 +32,14 @@ describe("CaseCapabilitiesSection", () => { for (const item of capabilities) { expect(screen.getByText(item)).toBeVisible(); } - const button = screen.getByRole("link", { name: buttonLabels.contact }); - expect(button).toHaveAttribute("target", "_blank"); - expect(button.className).toContain("bg-[var(--slash)]"); - expect(messageOf(button)).toContain( - `Quero conversar sobre ${similarProjectSubject(withSite.title)}.`, - ); - // o botão vem depois da lista - expect( - screen.getByText(capabilities[1]).compareDocumentPosition(button) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); + // o botão "Falar sobre meu projeto" é do fecho do case (CaseContactSection), não desta seção + expect(screen.queryByRole("link")).toBeNull(); + expect(container.querySelectorAll("a")).toHaveLength(0); }); it("path: a laboratory case with items -> the title does not talk about a client", () => { render( - , + , ); expect( @@ -82,55 +51,17 @@ describe("CaseCapabilitiesSection", () => { expect(screen.queryByText(/o cliente passou a poder fazer/i)).toBeNull(); }); - it("path: no items but a capture on disk (no site) -> only the contact button, under a heading that only screen readers get", () => { - mockedGetCaseMedia.mockImplementation((id, device) => - id === "projeto-teste" ? getShowcaseAssets(id, device) : undefined, - ); + it("path: no items -> renders nothing, with no button on its own even when the case has a capture or a site", () => { const { container } = render( - , + , ); - expect(container.querySelector("ul")).toBeNull(); - expect( - screen.getByRole("link", { name: buttonLabels.contact }), - ).toBeVisible(); - expect(screen.getByRole("heading", { level: 2 })).toHaveClass("sr-only"); + expect(container).toBeEmptyDOMElement(); }); - it("path: items but no 'Como ficou' -> the list stays and the button is not repeated", () => { - render( - , - ); - - expect(screen.getByText(capabilities[0])).toBeVisible(); - expect(screen.queryByRole("link")).toBeNull(); - }); - - it("path: no items and no 'Como ficou' -> renders nothing", () => { + it("path: an empty list of items -> renders nothing", () => { const { container } = render( - , + , ); expect(container).toBeEmptyDOMElement(); diff --git a/app/cases/_components/CaseCapabilitiesSection.tsx b/app/cases/_components/CaseCapabilitiesSection.tsx index 9adf24a..d37d558 100644 --- a/app/cases/_components/CaseCapabilitiesSection.tsx +++ b/app/cases/_components/CaseCapabilitiesSection.tsx @@ -1,60 +1,33 @@ -import { primaryButton } from "@/app/components/button-styles"; -import WhatsAppLink from "@/app/components/WhatsAppLink"; -import { buttonLabels, type CaseStudyContent } from "@/lib/site-content"; -import { similarProjectSubject } from "@/lib/whatsapp"; -import { hasCaseMedia } from "./CaseMediaSection"; +import type { CaseStudyContent } from "@/lib/site-content"; /** - * Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam) - * e, de novo, o botão de contato, para quem acabou de ver o resultado não precisar rolar até o fim da página. - * Sem itens, só o botão (e só quando há "Como ficou"); sem itens e sem mídia, a seção não aparece. + * Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam). + * Sem botão de contato: o fecho do case (CaseContactSection) vem logo depois e é o único convite ao WhatsApp, para não haver dois + * "Falar sobre meu projeto" seguidos. Sem itens, a seção não aparece. */ export function CaseCapabilitiesSection({ caseStudy, isClient, }: { - caseStudy: Pick< - CaseStudyContent, - "id" | "title" | "demoUrl" | "capabilities" - >; + caseStudy: Pick; isClient: boolean; }) { const capabilities = caseStudy.capabilities ?? []; - const showContact = hasCaseMedia(caseStudy); - if (capabilities.length === 0 && !showContact) return null; + if (capabilities.length === 0) return null; return (
- {capabilities.length > 0 ? ( - <> -

- {isClient - ? "O que o cliente passou a poder fazer" - : "O que dá para fazer com ele"} -

-
    - {capabilities.map((item) => ( -
  • {item}
  • - ))} -
- - ) : ( -

- Fale sobre o seu projeto -

- )} - {showContact ? ( -
- - {buttonLabels.contact} - -
- ) : null} +

+ {isClient + ? "O que o cliente passou a poder fazer" + : "O que dá para fazer com ele"} +

+
    + {capabilities.map((item) => ( +
  • {item}
  • + ))} +
); diff --git a/app/cases/page.paths.test.tsx b/app/cases/page.paths.test.tsx index 3b76fa6..6d0db9c 100644 --- a/app/cases/page.paths.test.tsx +++ b/app/cases/page.paths.test.tsx @@ -77,7 +77,7 @@ describe("CasesPage: cases de cliente", () => { ).toBeNull(); }); - it("path: contact -> 'Falar sobre meu projeto', and the list of clients opens with Lavação 047 and ends with the SEV", () => { + it("path: contact -> 'Falar sobre meu projeto', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus and AS Serviços", () => { const { container } = render(); expect( @@ -89,8 +89,16 @@ describe("CasesPage: cases de cliente", () => { expect(clientTitles).toEqual( clientCasesForListing.map(({ title }) => title), ); - expect(clientTitles[0]).toBe("Lavação 047"); - expect(clientTitles[clientTitles.length - 1]).toBe("SEV Moda Íntima"); + expect(clientTitles).toEqual([ + "SEV Moda Íntima", + "Lavação 047", + "SC Plus Aquicultura", + "AS Serviços", + ]); + // um botão de contato só na página: o do fecho + expect( + screen.getAllByRole("link", { name: buttonLabels.contact }), + ).toHaveLength(1); }); });