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.
796 lines
28 KiB
TypeScript
796 lines
28 KiB
TypeScript
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
|
import {
|
|
buttonLabels,
|
|
caseStudies,
|
|
clientCasesForListing,
|
|
getRelatedServices,
|
|
siteUrl,
|
|
} from "@/lib/site-content";
|
|
import { similarProjectSubject } from "@/lib/whatsapp";
|
|
import CasesPage from "../page";
|
|
import CasePage, { generateMetadata, generateStaticParams } from "./page";
|
|
|
|
beforeEach(() => {
|
|
setNavigation({ pathname: "/cases" });
|
|
});
|
|
|
|
const clientCaseIds = [
|
|
"sev-modaintima",
|
|
"lavacao-047",
|
|
"sc-plus",
|
|
"as-servicos",
|
|
"julia-beauty",
|
|
];
|
|
const labCaseIds = [
|
|
"nutriscan",
|
|
"twins-lab",
|
|
"reddit-explorer",
|
|
"gcc-installer",
|
|
];
|
|
const publicCaseIds = [...clientCaseIds, ...labCaseIds];
|
|
|
|
function getCase(id: string) {
|
|
const caseStudy = caseStudies.find((item) => item.id === id);
|
|
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
|
return caseStudy;
|
|
}
|
|
|
|
async function renderCase(id: string) {
|
|
const ui = await CasePage({ params: Promise.resolve({ slug: id }) });
|
|
return render(ui);
|
|
}
|
|
|
|
function isBefore(first: Element, second: Element) {
|
|
return Boolean(
|
|
first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
);
|
|
}
|
|
|
|
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];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo
|
|
* ("Chamar no WhatsApp") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
|
|
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Chamar no WhatsApp" seguidos.
|
|
*/
|
|
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
|
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);
|
|
|
|
const ctas = screen.getAllByRole("link", { name: buttonLabels.whatsapp });
|
|
expect(ctas).toHaveLength(1);
|
|
for (const cta of ctas) {
|
|
const destination = new URL(cta.getAttribute("href") ?? "");
|
|
expect(destination.origin).toBe("https://wa.me");
|
|
expect(destination.searchParams.get("text")).toContain(
|
|
similarProjectSubject(caseStudy.title),
|
|
);
|
|
expect(cta).toHaveAttribute("target", "_blank");
|
|
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
|
// limão preenchido só no contato: o botão de play e os demais não o usam
|
|
expect(cta.className).toContain("bg-[var(--slash)]");
|
|
}
|
|
}
|
|
|
|
it("exposes the nine public cases, the new client cases among them", async () => {
|
|
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
|
|
expect(staticSlugs).toHaveLength(9);
|
|
expect(new Set(staticSlugs).size).toBe(9);
|
|
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
|
|
expect(staticSlugs).toContain("sev-modaintima");
|
|
expect(staticSlugs).toContain("lavacao-047");
|
|
expect(staticSlugs).toContain("julia-beauty");
|
|
|
|
for (const id of [
|
|
"sev-modaintima",
|
|
"lavacao-047",
|
|
"as-servicos",
|
|
"julia-beauty",
|
|
]) {
|
|
const caseStudy = getCase(id);
|
|
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
|
const { metaTitle: title, metaDescription: description } = caseStudy;
|
|
const metadata = await generateMetadata({
|
|
params: Promise.resolve({ slug: caseStudy.id }),
|
|
});
|
|
expect(metadata).toMatchObject({
|
|
title,
|
|
description,
|
|
alternates: { canonical: url },
|
|
openGraph: { title, description, url },
|
|
twitter: { title, description },
|
|
});
|
|
expect(metadata).not.toHaveProperty("keywords");
|
|
}
|
|
});
|
|
|
|
it("renders a client case in the sales order, with media, contact and collapsed technical details", async () => {
|
|
const caseStudy = getCase("sev-modaintima");
|
|
const { container } = await renderCase(caseStudy.id);
|
|
|
|
const heading = screen.getByRole("heading", { level: 1 });
|
|
expect(heading).toHaveTextContent(caseStudy.headline);
|
|
expect(isBefore(heading, screen.getByText(caseStudy.summary))).toBe(true);
|
|
expect(screen.getByText(/Case de cliente/)).toBeVisible();
|
|
|
|
const sectionNames = [
|
|
"O cliente e o contexto",
|
|
"O problema",
|
|
"O que foi feito",
|
|
"Como ficou",
|
|
"O que o cliente passou a poder fazer",
|
|
"Quer um projeto parecido com este?",
|
|
];
|
|
const sectionHeadings = sectionNames.map((name) =>
|
|
screen.getByRole("heading", { level: 2, name }),
|
|
);
|
|
for (let index = 1; index < sectionHeadings.length; index += 1) {
|
|
expect(isBefore(sectionHeadings[index - 1], sectionHeadings[index])).toBe(
|
|
true,
|
|
);
|
|
}
|
|
expect(isBefore(heading, sectionHeadings[0])).toBe(true);
|
|
|
|
expect(screen.getByText(caseStudy.context)).toBeVisible();
|
|
expect(screen.getByText(caseStudy.problem)).toBeVisible();
|
|
for (const delivery of caseStudy.deliveries) {
|
|
expect(screen.getByText(delivery)).toBeVisible();
|
|
}
|
|
|
|
// sem dado real, não há seção de resultado
|
|
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
|
|
|
|
// mídia: o pôster do computador e do celular com um botão de play; nenhum vídeo parado com controles
|
|
expect(container.querySelector("video")).toBeNull();
|
|
const desktopPlay = screen.getByRole("button", {
|
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
|
});
|
|
const mobilePlay = screen.getByRole("button", {
|
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
|
|
});
|
|
const mediaSection = screen
|
|
.getByRole("heading", { level: 2, name: "Como ficou" })
|
|
.closest("section");
|
|
expect(mediaSection?.querySelector(".ds-bframe")).toContainElement(
|
|
desktopPlay,
|
|
);
|
|
expect(mediaSection?.querySelector(".ds-phone")).toContainElement(mobilePlay);
|
|
expect(
|
|
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
|
|
).toHaveAttribute("href", caseStudy.demoUrl);
|
|
|
|
// 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(
|
|
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
|
name: buttonLabels.whatsapp,
|
|
}),
|
|
).toBeNull();
|
|
const closingSection = sectionHeadings[5].closest("section");
|
|
expect(closingSection).toContainElement(
|
|
within(closingSection as HTMLElement).getByRole("link", {
|
|
name: buttonLabels.whatsapp,
|
|
}),
|
|
);
|
|
for (const capability of caseStudy.capabilities ?? []) {
|
|
expect(
|
|
within(capabilitiesSection as HTMLElement).getByText(capability),
|
|
).toBeVisible();
|
|
}
|
|
|
|
// detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte
|
|
const details = container.querySelector("details");
|
|
if (!details) throw new Error("Missing technical details");
|
|
expect(details).not.toHaveAttribute("open");
|
|
expect(within(details).getByText("Detalhes técnicos")).toBeVisible();
|
|
for (const technology of caseStudy.stack) {
|
|
expect(within(details).getByText(technology)).not.toBeVisible();
|
|
}
|
|
for (const decision of caseStudy.decisions) {
|
|
expect(within(details).getByText(decision)).not.toBeVisible();
|
|
}
|
|
const doneSection = sectionHeadings[2].closest("section");
|
|
expect(doneSection).toContainElement(details);
|
|
expect(isBefore(screen.getByText(caseStudy.deliveries[0]), details)).toBe(
|
|
true,
|
|
);
|
|
expect(isBefore(details, sectionHeadings[3])).toBe(true);
|
|
expect(container.querySelectorAll("details")).toHaveLength(1);
|
|
expect(
|
|
container.querySelector("section[aria-label='Detalhes técnicos']"),
|
|
).toBeNull();
|
|
|
|
// o que saiu: desafio verificável, limites da evidência, fontes e commits
|
|
for (const name of [
|
|
"Desafio verificável",
|
|
"Decisões de implementação",
|
|
"Limites desta evidência",
|
|
"Fontes",
|
|
]) {
|
|
expect(screen.queryByRole("heading", { name })).toBeNull();
|
|
}
|
|
expect(screen.queryByRole("link", { name: /^Fonte:/ })).toBeNull();
|
|
expect(container.textContent).not.toMatch(/Commit:|[0-9a-f]{40}/);
|
|
|
|
const graph = readJsonLdEntities(container);
|
|
expect(graph.map((node) => node["@type"])).toEqual([
|
|
"CreativeWork",
|
|
"BreadcrumbList",
|
|
]);
|
|
expect(JSON.stringify(graph)).not.toMatch(
|
|
/Article|FAQPage|ProfessionalService|LocalBusiness/,
|
|
);
|
|
const breadcrumb = graph[1] as {
|
|
itemListElement: Array<{
|
|
"@type": string;
|
|
position: number;
|
|
name: string;
|
|
item: string;
|
|
}>;
|
|
};
|
|
const canonicalUrl = `${siteUrl}/cases/${caseStudy.id}`;
|
|
expect(breadcrumb.itemListElement).toEqual([
|
|
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
|
{
|
|
"@type": "ListItem",
|
|
position: 2,
|
|
name: "Cases",
|
|
item: `${siteUrl}/cases`,
|
|
},
|
|
{
|
|
"@type": "ListItem",
|
|
position: 3,
|
|
name: caseStudy.title,
|
|
item: canonicalUrl,
|
|
},
|
|
]);
|
|
|
|
const visibleBreadcrumb = screen.getByRole("navigation", {
|
|
name: "Trilha de navegação",
|
|
});
|
|
expect(
|
|
within(visibleBreadcrumb)
|
|
.getAllByRole("link")
|
|
.map((link) => ({
|
|
name: link.textContent,
|
|
href: link.getAttribute("href"),
|
|
current: link.getAttribute("aria-current"),
|
|
})),
|
|
).toEqual([
|
|
{ name: "Início", href: "/", current: null },
|
|
{ name: "Cases", href: "/cases", current: null },
|
|
{
|
|
name: caseStudy.title,
|
|
href: `/cases/${caseStudy.id}`,
|
|
current: "page",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("renders the two new client cases from the portfolio data", async () => {
|
|
for (const id of ["sev-modaintima", "lavacao-047"]) {
|
|
const caseStudy = getCase(id);
|
|
const { unmount } = await renderCase(id);
|
|
|
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
caseStudy.headline,
|
|
);
|
|
expect(
|
|
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
|
|
).toHaveAttribute("href", caseStudy.demoUrl);
|
|
// um botão de contato só, no fecho, mesmo com "Como ficou" na página
|
|
expectContactAndNoDeadLinks(caseStudy);
|
|
unmount();
|
|
}
|
|
});
|
|
|
|
it("labels a laboratory case, skips the media block and shows its service as a card", async () => {
|
|
const caseStudy = getCase("twins-lab");
|
|
const { container } = await renderCase(caseStudy.id);
|
|
|
|
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
|
|
expect(
|
|
screen.getByRole("heading", { level: 2, name: "O contexto do projeto" }),
|
|
).toBeVisible();
|
|
expect(screen.queryByRole("heading", { name: "Como ficou" })).toBeNull();
|
|
expect(container.querySelector("video")).toBeNull();
|
|
expect(container.querySelector(".ds-bframe")).toBeNull();
|
|
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
|
|
expect(screen.queryByText(/indispon/i)).toBeNull();
|
|
|
|
const relatedLinks = screen.getAllByRole("link", {
|
|
name: /^Ver o serviço:/,
|
|
});
|
|
expect(relatedLinks.map((link) => link.getAttribute("href"))).toEqual(
|
|
getRelatedServices(caseStudy.id).map(({ slug }) => `/servicos/${slug}`),
|
|
);
|
|
expect(relatedLinks).toHaveLength(1);
|
|
expectContactAndNoDeadLinks(caseStudy);
|
|
});
|
|
|
|
it("turns the contact of a laboratory case into the 'Fale sobre o seu caso' invitation, with the same button label as a client case", async () => {
|
|
for (const id of labCaseIds) {
|
|
const { unmount } = await renderCase(id);
|
|
|
|
expect(screen.queryByText("Quer um projeto parecido com este?")).toBeNull();
|
|
expect(
|
|
screen.queryByRole("link", {
|
|
name: /Conversar sobre um projeto parecido|Tem um problema parecido/,
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
|
).toBeVisible();
|
|
// 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", {
|
|
name: "O que o cliente passou a poder fazer",
|
|
}),
|
|
).toBeNull();
|
|
unmount();
|
|
}
|
|
});
|
|
|
|
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);
|
|
|
|
const media = screen.getByRole("heading", { level: 2, name: "Como ficou" });
|
|
const capabilities = screen.getByRole("heading", {
|
|
level: 2,
|
|
name: "O que o cliente passou a poder fazer",
|
|
});
|
|
// a seção vem logo depois da mídia, sem outra seção no meio
|
|
const mediaSection = media.closest("section");
|
|
const capabilitiesSection = capabilities.closest("section");
|
|
expect(mediaSection?.nextElementSibling).toBe(capabilitiesSection);
|
|
const items = within(capabilitiesSection as HTMLElement)
|
|
.getAllByRole("listitem")
|
|
.map((item) => item.textContent);
|
|
expect(items).toEqual(caseStudy.capabilities);
|
|
expect(items.length).toBeGreaterThanOrEqual(2);
|
|
expect(items.length).toBeLessThanOrEqual(3);
|
|
// sem número inventado
|
|
expect(items.join(" ")).not.toMatch(/\d/);
|
|
// o único botão de contato é o do fecho, depois da lista: não há dois "Chamar no WhatsApp" seguidos
|
|
expect(
|
|
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
|
name: buttonLabels.whatsapp,
|
|
}),
|
|
).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.whatsapp,
|
|
}),
|
|
).toHaveLength(1);
|
|
unmount();
|
|
}
|
|
|
|
// a frase do dono da Lavação 047 vem dos dados do projeto
|
|
await renderCase("lavacao-047");
|
|
expect(
|
|
screen.getByText(
|
|
"O dono muda preços, textos, fotos e o mapa sem pedir a ninguém, e a mudança aparece no site sem nova publicação.",
|
|
),
|
|
).toBeVisible();
|
|
});
|
|
|
|
it("shows the author's role and no site button for a client project without a public site", async () => {
|
|
const caseStudy = getCase("as-servicos");
|
|
expect(caseStudy.demoUrl).toBeNull();
|
|
await renderCase(caseStudy.id);
|
|
|
|
expect(screen.getByText("Minha participação:")).toBeVisible();
|
|
expect(
|
|
screen.getByText(caseStudy.role ?? "", { exact: false }),
|
|
).toBeVisible();
|
|
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
|
|
expect(screen.queryByText(/indispon/i)).toBeNull();
|
|
// a capa continua nas molduras
|
|
expect(
|
|
screen.getByRole("button", {
|
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
|
}),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the result section only when the case has real data", async () => {
|
|
const caseStudy = getCase("lavacao-047");
|
|
const { unmount } = await renderCase(caseStudy.id);
|
|
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
|
|
unmount();
|
|
|
|
caseStudy.result = ["Dado real informado pelo cliente."];
|
|
try {
|
|
await renderCase(caseStudy.id);
|
|
const heading = screen.getByRole("heading", {
|
|
level: 2,
|
|
name: "Resultado",
|
|
});
|
|
expect(screen.getByText("Dado real informado pelo cliente.")).toBeVisible();
|
|
expect(
|
|
isBefore(
|
|
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
|
|
heading,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isBefore(
|
|
heading,
|
|
screen.getByRole("heading", {
|
|
level: 2,
|
|
name: "Quer um projeto parecido com este?",
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
delete caseStudy.result;
|
|
}
|
|
});
|
|
|
|
it("shows at most two related services, as cards under the contact button", async () => {
|
|
// lavacao-047 tem três serviços ligados; só os dois primeiros viram cards
|
|
const sev = getCase("lavacao-047");
|
|
const all = getRelatedServices(sev.id);
|
|
expect(all.length).toBeGreaterThan(2);
|
|
const { unmount } = await renderCase(sev.id);
|
|
|
|
const cards = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
|
|
expect(cards).toHaveLength(2);
|
|
expect(cards.map((link) => link.getAttribute("href"))).toEqual(
|
|
all.slice(0, 2).map(({ slug }) => `/servicos/${slug}`),
|
|
);
|
|
for (const link of cards) {
|
|
const card = link.closest("article");
|
|
expect(card).toHaveClass("ds-card");
|
|
expect(
|
|
within(card as HTMLElement).getByRole("heading", { level: 3 }),
|
|
).toBeVisible();
|
|
}
|
|
expect(
|
|
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
|
|
).toBeNull();
|
|
// o botão do fecho (o único) vem antes dos cartões
|
|
const contact = screen.getByRole("link", { name: buttonLabels.whatsapp });
|
|
expect(isBefore(contact, cards[0])).toBe(true);
|
|
unmount();
|
|
|
|
// 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", 2],
|
|
] as const) {
|
|
const { unmount: unmountCase } = await renderCase(id);
|
|
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
|
|
expect(links).toHaveLength(expected);
|
|
expect(links.map((link) => link.getAttribute("href"))).toEqual(
|
|
getRelatedServices(id)
|
|
.slice(0, 2)
|
|
.map(({ slug }) => `/servicos/${slug}`),
|
|
);
|
|
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);
|
|
|
|
const work = readJsonLdEntities(container).find(
|
|
(node) => node["@type"] === "CreativeWork",
|
|
);
|
|
expect(work).toEqual({
|
|
"@type": "CreativeWork",
|
|
"@id": `${siteUrl}/cases/lavacao-047#creative-work`,
|
|
name: "Lavação 047: Site e painel para lavação automotiva",
|
|
description: caseStudy.metaDescription,
|
|
url: `${siteUrl}/cases/lavacao-047`,
|
|
image: `${siteUrl}/cases/lavacao-047/opengraph-image/lavacao-047`,
|
|
inLanguage: "pt-BR",
|
|
creator: { "@id": `${siteUrl}/#person` },
|
|
sourceOrganization: {
|
|
"@type": "Organization",
|
|
name: "Lavação 047",
|
|
url: "https://landing-page-lavacao047.vercel.app",
|
|
},
|
|
keywords: caseStudy.stack.join(", "),
|
|
});
|
|
});
|
|
|
|
it("writes the CreativeWork of a laboratory case without a client", async () => {
|
|
const caseStudy = getCase("twins-lab");
|
|
const { container } = await renderCase(caseStudy.id);
|
|
|
|
const work = readJsonLdEntities(container).find(
|
|
(node) => node["@type"] === "CreativeWork",
|
|
);
|
|
expect(work).toMatchObject({
|
|
name: "Twins Lab: Painel ao vivo de um reator simulado",
|
|
url: `${siteUrl}/cases/twins-lab`,
|
|
creator: { "@id": `${siteUrl}/#person` },
|
|
keywords: "Next.js, TypeScript, NextAuth, MongoDB, SSE",
|
|
});
|
|
expect(work).not.toHaveProperty("sourceOrganization");
|
|
});
|
|
|
|
it("puts the project cover next to the title, and below it on a phone, in a browser frame", async () => {
|
|
const caseStudy = getCase("sev-modaintima");
|
|
const { container } = await renderCase(caseStudy.id);
|
|
|
|
const hero = container.querySelector("section");
|
|
if (!hero) throw new Error("Missing hero");
|
|
const frame = hero.querySelector(".ds-bframe");
|
|
if (!frame) throw new Error("Missing hero frame");
|
|
expect(frame.querySelector("img")).toHaveAttribute(
|
|
"alt",
|
|
`Captura de tela do site ${caseStudy.title}`,
|
|
);
|
|
expect(frame.querySelector(".ds-bframe-url")).toHaveTextContent(
|
|
"sevmodaintima.com.br",
|
|
);
|
|
// a capa abre a página: carrega antes das demais imagens
|
|
expect(frame.querySelector("img")).not.toHaveAttribute("loading", "lazy");
|
|
|
|
// ordem do documento (a do celular): título, mídia, resumo
|
|
const heading = screen.getByRole("heading", { level: 1 });
|
|
const summary = screen.getByText(caseStudy.summary);
|
|
expect(hero).toContainElement(summary);
|
|
expect(isBefore(heading, frame)).toBe(true);
|
|
expect(isBefore(frame, summary)).toBe(true);
|
|
// no computador: duas colunas, texto à esquerda e mídia à direita
|
|
const grid = frame.closest(".grid");
|
|
expect(grid?.className).toContain("lg:grid-cols-[");
|
|
expect(frame.parentElement?.className).toContain("lg:col-start-2");
|
|
expect(heading.parentElement?.className).toContain("lg:col-start-1");
|
|
});
|
|
|
|
it("keeps the hero in one column when the case has no capture", async () => {
|
|
const { container } = await renderCase("twins-lab");
|
|
|
|
const hero = container.querySelector("section");
|
|
expect(hero?.querySelector(".ds-bframe")).toBeNull();
|
|
expect(hero?.querySelector(".grid")).toBeNull();
|
|
});
|
|
|
|
it("shows the optional facts only when they are filled in", async () => {
|
|
const caseStudy = getCase("lavacao-047");
|
|
const empty = await renderCase(caseStudy.id);
|
|
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
|
|
empty.unmount();
|
|
|
|
caseStudy.facts = ["Fato de teste um", "Fato de teste dois"];
|
|
try {
|
|
await renderCase(caseStudy.id);
|
|
const list = screen.getByRole("list", { name: "Fatos do projeto" });
|
|
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
|
expect(within(list).getByText("Fato de teste um")).toBeVisible();
|
|
expect(isBefore(screen.getByText(caseStudy.summary), list)).toBe(true);
|
|
} finally {
|
|
delete caseStudy.facts;
|
|
}
|
|
});
|
|
|
|
it("starts the video only when the visitor presses play, and moves focus to it", async () => {
|
|
const caseStudy = getCase("sev-modaintima");
|
|
const { container } = await renderCase(caseStudy.id);
|
|
|
|
const play = screen.getByRole("button", {
|
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
|
});
|
|
expect(play).toBeVisible();
|
|
expect(play.className).not.toContain("--slash");
|
|
expect(container.querySelector("video")).toBeNull();
|
|
|
|
fireEvent.click(play);
|
|
|
|
const video = screen.getByLabelText(
|
|
`Vídeo do site ${caseStudy.title} no computador`,
|
|
);
|
|
expect(video.tagName).toBe("VIDEO");
|
|
expect(video).toHaveAttribute(
|
|
"poster",
|
|
`/videos/${caseStudy.id}/desktop.webp`,
|
|
);
|
|
expect(video).toHaveAttribute("controls");
|
|
expect(video).toHaveFocus();
|
|
expect(
|
|
video.querySelectorAll("source[src^='/videos/sev-modaintima/']"),
|
|
).toHaveLength(2);
|
|
expect(video.closest(".ds-bframe")).not.toBeNull();
|
|
expect(
|
|
screen.queryByRole("button", {
|
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
|
}),
|
|
).toBeNull();
|
|
// o celular continua com o pôster até a pessoa tocar nele
|
|
expect(
|
|
screen.getByRole("button", {
|
|
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
|
|
}),
|
|
).toBeVisible();
|
|
});
|
|
|
|
it("gives the case page the same shell as the home, with no narrow max-width", async () => {
|
|
const { container } = await renderCase("sev-modaintima");
|
|
for (const shell of container.querySelectorAll(".ds-section-shell")) {
|
|
expect(shell.className).not.toMatch(/max-w-/);
|
|
}
|
|
});
|
|
|
|
it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => {
|
|
const { container } = render(<CasesPage />);
|
|
const heading = screen.getByRole("heading", { level: 1 });
|
|
expect(heading.nextElementSibling).toHaveTextContent(/Abra um case/);
|
|
// o título e o subtítulo não repetem "Veja o que já"
|
|
expect(heading.nextElementSibling).not.toHaveTextContent(/Veja o que já/);
|
|
expect(container.textContent).not.toMatch(/Selected work/i);
|
|
|
|
const clientHeading = screen.getByRole("heading", {
|
|
level: 2,
|
|
name: "Projetos de clientes",
|
|
});
|
|
const labHeading = screen.getByRole("heading", {
|
|
level: 2,
|
|
name: "Laboratório",
|
|
});
|
|
expect(isBefore(heading, clientHeading)).toBe(true);
|
|
expect(isBefore(clientHeading, labHeading)).toBe(true);
|
|
expect(screen.getByText(/Projetos pessoais e de estudo/)).toBeVisible();
|
|
|
|
// 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).toEqual([
|
|
"sev-modaintima",
|
|
"lavacao-047",
|
|
"sc-plus",
|
|
"as-servicos",
|
|
"julia-beauty",
|
|
]);
|
|
const clientLinks = listedIds.map((id) => {
|
|
const card = container
|
|
.querySelector(`a[href="/cases/${id}"]`)
|
|
?.closest("article");
|
|
if (!card) throw new Error(`Missing client card ${id}`);
|
|
expect(isBefore(clientHeading, card)).toBe(true);
|
|
expect(isBefore(card, labHeading)).toBe(true);
|
|
expect(card.querySelector(".ds-bframe img")).toHaveAttribute(
|
|
"alt",
|
|
`Captura de tela do site ${getCase(id).title}`,
|
|
);
|
|
return within(card).getByRole("link", {
|
|
name: `${buttonLabels.case} ${getCase(id).title}`,
|
|
});
|
|
});
|
|
expect(clientLinks.map((link) => link.getAttribute("href"))).toEqual(
|
|
listedIds.map((id) => `/cases/${id}`),
|
|
);
|
|
|
|
// laboratório: lista compacta, uma linha por projeto (nome, o que é, "Projeto próprio" e o link), sem capa nem cartão
|
|
const labLinks = within(
|
|
labHeading.closest("section") as HTMLElement,
|
|
).getAllByRole("link", { name: /^Ver case / });
|
|
expect(labLinks.map((link) => link.getAttribute("href"))).toEqual(
|
|
labCaseIds.map((id) => `/cases/${id}`),
|
|
);
|
|
const labList = labLinks[0].closest("ul");
|
|
expect(labList?.querySelectorAll(":scope > li")).toHaveLength(
|
|
labCaseIds.length,
|
|
);
|
|
expect(labList?.querySelector("article, .ds-card, .ds-bframe")).toBeNull();
|
|
labLinks.forEach((link, index) => {
|
|
const row = link.closest("li");
|
|
const caseStudy = getCase(labCaseIds[index]);
|
|
expect(isBefore(labHeading, link)).toBe(true);
|
|
expect(link).toHaveAccessibleName(`Ver case ${caseStudy.title}`);
|
|
expect(
|
|
within(row as HTMLElement).getByRole("heading", {
|
|
level: 3,
|
|
name: caseStudy.title,
|
|
}),
|
|
).toBeVisible();
|
|
expect(
|
|
within(row as HTMLElement).getByText(caseStudy.subtitle),
|
|
).toBeVisible();
|
|
expect(
|
|
within(row as HTMLElement).getByText("Projeto próprio"),
|
|
).toBeVisible();
|
|
// sem o resumo longo do cartão antigo
|
|
expect(row).not.toHaveTextContent(caseStudy.summary);
|
|
});
|
|
|
|
// contato pelo WhatsApp, sem /#contact nem GitHub
|
|
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);
|
|
expect(hrefs).toContain("/servicos");
|
|
const cta = screen.getByRole("link", { name: buttonLabels.whatsapp });
|
|
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
|
|
|
|
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", "/"],
|
|
["Cases", "/cases"],
|
|
]);
|
|
|
|
const graph = readJsonLdEntities(container);
|
|
expect(graph.map((node) => node["@type"])).toEqual([
|
|
"CollectionPage",
|
|
"ItemList",
|
|
"BreadcrumbList",
|
|
]);
|
|
const itemList = graph[1] as {
|
|
itemListElement: Array<{
|
|
"@type": string;
|
|
position: number;
|
|
name: string;
|
|
url: string;
|
|
}>;
|
|
};
|
|
const visibleItems = [...clientLinks, ...labLinks].map((link, index) => {
|
|
const href = link.getAttribute("href");
|
|
// cartão do cliente ou linha da lista do laboratório
|
|
const card = link.closest("article, li");
|
|
if (!card) throw new Error(`Missing visible card for ${href}`);
|
|
const expected = getCase((href ?? "").replace("/cases/", ""));
|
|
expect(card).toHaveTextContent(expected.title);
|
|
return {
|
|
"@type": "ListItem",
|
|
position: index + 1,
|
|
name: expected.title,
|
|
url: `${siteUrl}${href}`,
|
|
};
|
|
});
|
|
expect(itemList.itemListElement).toEqual(visibleItems);
|
|
});
|