fix(cases): um só convite ao WhatsApp no fim do case e testes alinhados

This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 22:54:19 -03:00
1 parent 2fd8b063d9
commit d356c364e0
7 files changed
+124 -197

No files matched your search

@@ -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);
+4 -5
View File
@@ -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);
+7 -10
View File
@@ -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");
+67 -42
View File
@@ -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<string, unknown>[] {
/**
* 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}"]`)