import { render, screen, within } from "@testing-library/react"; import { serviceOffers } from "@/lib/site-content"; import { primaryButton } from "./button-styles"; import { WorkModalities } from "./WorkModalities"; /** * Testes de caminhos-base de WorkModalities e OfferCard: a entrada das ofertas (Reveal, CSS), o toque e o passe do * mouse (classes cv-press e cv-lift, sem foco nos cartões), as quatro ofertas (nome e "O que você recebe" vindos de * `serviceOffers`, foto do case, vídeo ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo" * uma vez só depois dos cartões), a faixa da nota fiscal acima deles, o estilo único de cartão, o texto de pelo menos * 15 px e a lista compacta de etapas. * Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css). */ jest.mock("./Section", () => ({ Section: ({ children, id, title, description, }: { children?: React.ReactNode; id?: string; title?: string; description?: string; }) => (

{title}

{description}

{children}
), })); jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null })); // o jsdom não toca vídeo: os cartões do sistema e do atendimento chamam play() ao aparecer beforeEach(() => { jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); }); const HOME_OFFERS = serviceOffers.filter(({ id }) => id !== "fiscal"); const original = (image: Element) => new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get( "url", ); describe("WorkModalities: movimento", () => { it("path: offers enter through Reveal -> same rise, same duration and a growing delay, no JavaScript animation props", () => { const { container } = render(); const reveals = [...container.querySelectorAll(".ds-reveal")]; expect(reveals).toHaveLength(4); expect( reveals.map((element) => element.style.getPropertyValue("--reveal-y")), ).toEqual(["20px", "20px", "20px", "20px"]); expect( reveals.map((element) => element.style.getPropertyValue("--reveal-duration"), ), ).toEqual(["0.75s", "0.75s", "0.75s", "0.75s"]); expect( reveals.map((element) => Number.parseFloat(element.style.getPropertyValue("--reveal-delay")), ), ).toEqual([0.08, 0.16, 0.24, 0.32]); // o estado escondido vive no CSS: sem opacidade nem transformação inline for (const element of reveals) { expect(element.style.opacity).toBe(""); expect(element.style.transform).toBe(""); } }); it("path: the cards are not focus stops and keep no outline-none (touch and hover feedback come from CSS)", () => { const { container } = render(); const cards = container.querySelectorAll("article"); expect(cards).toHaveLength(4); for (const card of cards) { expect(card).not.toHaveAttribute("tabindex"); expect(card.className).not.toContain("outline-none"); expect(card).toHaveClass("cv-press", "cv-lift"); } }); it("path: each offer sits alone in its own reveal wrapper, which can shrink in the grid", () => { const { container } = render(); for (const card of container.querySelectorAll("article")) { const wrapper = card.closest(".ds-reveal"); expect(wrapper).toHaveClass("min-w-0"); expect(wrapper?.querySelectorAll("article")).toHaveLength(1); } }); }); describe("WorkModalities: as quatro ofertas", () => { it("path: the section title is 'Escolha por onde começar' and the offers follow the headline, in order, with the names of serviceOffers", () => { const { container } = render(); expect( screen.getByRole("heading", { level: 2, name: "Escolha por onde começar", }), ).toBeInTheDocument(); const articles = Array.from(container.querySelectorAll("article")); expect(articles).toHaveLength(4); const titles = articles.map( (article) => within(article).getByRole("heading", { level: 3 }).textContent, ); expect(titles).toEqual([ "Site que traz pedidos pelo WhatsApp", "Sistema para tirar a operação da planilha", "Atendimento no WhatsApp com IA sob controle", "Revisão de sistema que já existe", ]); // a fonte é uma só, a mesma de /servicos; a nota fiscal fica na faixa, sem cartão expect(titles).toEqual(HOME_OFFERS.map(({ name }) => name)); expect( screen.getByText( "Quatro pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado.", ), ).toBeInTheDocument(); }); it("path: two columns from 1024 px up and one below it", () => { const { container } = render(); const grid = container.querySelector("article")?.closest(".grid"); expect(grid).toHaveClass("grid-cols-1", "lg:grid-cols-2"); expect(grid?.className).not.toMatch(/grid-cols-3/); }); it("path: the site offer cites the Lavação 047 and its deliverables are the ones of serviceOffers, the same list of /servicos", () => { const { container } = render(); const [site] = Array.from(container.querySelectorAll("article")); expect(site.textContent).toMatch(/Lavação 047/); expect( within(site) .getAllByRole("listitem") .map((item) => item.textContent), ).toEqual(serviceOffers[0].deliverables); }); it("path: only the site offer shows a photo (the Lavação 047 poster, sized for half the row); the system, the WhatsApp and the review cards loop a video", () => { const { container } = render(); const [site, system, whatsapp, review] = Array.from( container.querySelectorAll("article"), ); const siteImage = within(site).getByRole("img"); expect(original(siteImage)).toBe("/videos/lavacao-047/desktop.webp"); expect(siteImage).toHaveAccessibleName( "Site Lavação 047 aberto no computador", ); expect(siteImage).toHaveAttribute( "sizes", "(min-width: 1024px) 50vw, 100vw", ); expect(siteImage).toHaveAttribute("loading", "lazy"); // a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema for (const card of [system, whatsapp, review]) { expect(card.querySelector("img")).toBeNull(); } expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp"); expect(site.querySelector("video")).toBeNull(); expect(review.querySelector("video")).not.toBeNull(); }); it("path: the system, the WhatsApp and the review videos are decorative 3D pieces: muted loop, no preload, the poster first, webm then mp4", () => { render(); for (const id of ["sistema", "whatsapp", "revisao"]) { const art = screen.getByTestId(`offer-art-${id}`); const video = art.querySelector("video") as HTMLVideoElement; expect(video).not.toBeNull(); expect(video).toHaveAttribute("aria-hidden", "true"); expect(video).toHaveAttribute("tabindex", "-1"); expect(video.muted).toBe(true); expect(video.loop).toBe(true); expect(video).toHaveAttribute("playsinline"); expect(video).toHaveAttribute("preload", "none"); expect(video).toHaveAttribute("poster", `/3d/peca-${id}.webp`); expect( Array.from(video.querySelectorAll("source")).map((source) => [ source.getAttribute("src"), source.getAttribute("type"), ]), ).toEqual([ [`/3d/peca-${id}.webm`, "video/webm"], [`/3d/peca-${id}.mp4`, "video/mp4"], ]); } // a planilha em SVG saiu do cartão do sistema expect( screen.getByTestId("offer-art-sistema").querySelector("svg"), ).toBeNull(); }); it("path: the WhatsApp video carries a decorative app badge in the top left corner: the WhatsApp mark in its green and the word, light on dark", () => { render(); const art = screen.getByTestId("offer-art-whatsapp"); expect(art.querySelector("video")?.parentElement).toHaveClass("relative"); const badge = within(art).getByText("WhatsApp"); expect(badge).toHaveAttribute("aria-hidden", "true"); expect(badge).toHaveClass("absolute", "top-2", "left-2", "text-white"); expect(badge.className).toMatch(/bg-black\/\d+/); expect(badge.className).not.toMatch(/rounded/); const mark = badge.querySelector("svg"); expect(mark).not.toBeNull(); expect(mark?.getAttribute("class")).toContain("text-[var(--whatsapp)]"); // só o vídeo do atendimento leva o selo for (const id of ["sistema", "revisao"]) { expect( within(screen.getByTestId(`offer-art-${id}`)).queryByText("WhatsApp"), ).toBeNull(); } }); it("path: the report excerpt lies over the right edge of the review video, vertically centered, in a wide box, and below the frame in a narrow one, 20% larger (18 px)", () => { render(); const art = screen.getByTestId("offer-art-revisao"); expect(art).toHaveClass("@container", "relative"); const excerpt = within(art).getByRole("img"); // depois do quadro do vídeo, nunca dentro dele: na caixa estreita fica embaixo, sem cobrir a janela expect( excerpt.previousElementSibling?.querySelector("video"), ).not.toBeNull(); expect(excerpt).not.toHaveClass("absolute"); expect(excerpt).toHaveClass( "@[29rem]:absolute", "@[29rem]:inset-y-0", "@[29rem]:right-0", "@[29rem]:items-end", "@[29rem]:justify-center", ); for (const item of excerpt.querySelectorAll("li")) { expect(item).toHaveClass("text-[18px]"); } expect(within(excerpt).getByText("Exemplo ilustrativo")).toHaveClass( "text-[18px]", ); }); it("path: no black box with an icon: the photo and the videos sit in the same frame, in the same box", () => { const { container } = render(); expect(screen.queryByTestId("offer-icon")).toBeNull(); const boxes = HOME_OFFERS.map(({ id }) => screen.getByTestId(`offer-art-${id}`), ); // as quatro caixas e os quatro quadros têm as mesmas classes, então o mesmo tamanho expect(new Set(boxes.map((box) => box.className)).size).toBe(1); const frames = boxes.map((box) => box.firstElementChild as HTMLElement); expect(new Set(frames.map((frame) => frame.className)).size).toBe(1); for (const frame of frames) { expect(frame.className).toContain("aspect-[16/10]"); expect(frame.className).toContain("md:aspect-[21/9]"); expect(frame.className).toContain("lg:aspect-[16/10]"); expect(frame.querySelector("img, video")).not.toBeNull(); } expect(container.querySelectorAll("article")).toHaveLength(4); }); it("path: the review illustration is an excerpt of the report model of /ia: three findings with a severity badge and the caption 'Exemplo ilustrativo'", () => { render(); const art = screen.getByTestId("offer-art-revisao"); expect(within(art).getByRole("img")).toHaveAccessibleName( "Exemplo ilustrativo de relatório da revisão: três achados, dois de gravidade alta e um de gravidade média", ); expect(within(art).getByText("Exemplo ilustrativo")).toBeInTheDocument(); const findings = Array.from(art.querySelectorAll("li")).map( (item) => item.textContent, ); expect(findings).toEqual([ "Dados expostosAlta", "Chave expostaAlta", "Sem backupMédia", ]); // a gravidade está no texto do selo, não só na cor for (const severity of ["Alta", "Média"]) { expect(within(art).getAllByText(severity).length).toBeGreaterThan(0); } // o texto do recorte tem 18 px embaixo do vídeo e 15 px ao lado da janela; nunca abaixo dos 15 px do resto da seção expect(art.innerHTML).not.toMatch(/text-\[(?:[0-9]|1[0-4])px\]/); }); it("path: the deliverables of the system, the WhatsApp and the review come from serviceOffers, the same lists of /servicos", () => { const { container } = render(); const articles = Array.from(container.querySelectorAll("article")); HOME_OFFERS.forEach((offer, index) => { expect( within(articles[index]) .getAllByRole("listitem") .map((item) => item.textContent), ).toEqual(offer.deliverables); }); }); it("path: every card says what you receive, with three or four items, once", () => { const { container } = render(); for (const card of container.querySelectorAll("article")) { expect( within(card as HTMLElement).getAllByText("O que você recebe"), ).toHaveLength(1); const items = within(card as HTMLElement).getAllByRole("listitem"); expect(items.length).toBeGreaterThanOrEqual(3); expect(items.length).toBeLessThanOrEqual(4); } }); it("path: the first step shows once, below the cards: a first conversation and, after it, the scope and the proposal in writing", () => { const { container } = render(); for (const card of container.querySelectorAll("article")) { expect(within(card).queryByText("Primeiro passo")).toBeNull(); } expect(screen.getAllByText("Primeiro passo")).toHaveLength(1); const label = screen.getByText("Primeiro passo"); expect(label.closest("article")).toBeNull(); // fica depois da grade de cartões const grid = container.querySelector(".grid") as HTMLElement; expect( grid.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect(label.parentElement?.textContent).toMatch( /^Primeiro passoPrimeira conversa para entender .+\. Depois, escopo e proposta por escrito\.$/, ); // sem prazo, sem preço expect(label.parentElement?.textContent).not.toMatch(/R\$|dias|prazo de/i); }); it("path: each button opens the contact form card with the project type already chosen, all four with the same filled style", () => { const { container } = render(); const articles = Array.from(container.querySelectorAll("article")); const buttons = [ ["Começar pelo site", "/?tipo=site#contact-form"], ["Começar pelo sistema", "/?tipo=sistema#contact-form"], ["Começar pelo atendimento", "/?tipo=whatsapp#contact-form"], ["Começar pela revisão", "/?tipo=revisao#contact-form"], ] as const; buttons.forEach(([name, href], index) => { const button = within(articles[index]).getByRole("link", { name }); expect(button).toHaveAttribute("href", href); expect(button.className).toContain(primaryButton); }); }); it("path: each offer links to the case or the page that backs it", () => { const { container } = render(); const articles = Array.from(container.querySelectorAll("article")); expect( within(articles[0]).getByRole("link", { name: "Ver case Lavação 047" }), ).toHaveAttribute("href", "/cases/lavacao-047"); expect( within(articles[1]).getByRole("link", { name: "Ver case SEV Moda Íntima", }), ).toHaveAttribute("href", "/cases/sev-modaintima"); expect( within(articles[2]).getByRole("link", { name: "Ver como funciona o atendimento", }), ).toHaveAttribute("href", "/servicos/atendimento-whatsapp-ia"); expect( within(articles[3]).getByRole("link", { name: "Ver como funciona a revisão", }), ).toHaveAttribute("href", "/ia"); expect( within(articles[2]).getByText( "Para quem atende e vende pelo WhatsApp e quer responder na hora sem deixar a IA inventar preço ou prazo.", ), ).toBeInTheDocument(); }); it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel, only one link to /servicos after the cards", () => { const { container } = render(); expect(screen.queryByText(/Reconhece algum destes cenários/)).toBeNull(); expect(screen.queryByText(/Planilhas demais/)).toBeNull(); const services = screen.getByRole("link", { name: "Ver todos os serviços", }); expect(services).toHaveAttribute("href", "/servicos"); expect(container.querySelectorAll("article")).toHaveLength(4); // o link fica fora dos cartões, depois da grade expect(services.closest("article")).toBeNull(); expect( screen .getAllByRole("link") .filter((link) => link.getAttribute("href") === "/servicos"), ).toEqual([services]); }); }); describe("WorkModalities: um só estilo de cartão", () => { it("path: the four cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => { const { container } = render(); const articles = Array.from(container.querySelectorAll("article")); const classNames = articles.map((article) => article.className); expect(new Set(classNames).size).toBe(1); for (const article of articles) { expect(article).not.toHaveAttribute("style"); expect(article.className).not.toContain("border-[var(--slash)]"); expect(article.className).not.toContain("shadow"); } }); it("path: every title reserves the same minimum height, so the texts below line up", () => { const { container } = render(); for (const article of container.querySelectorAll("article")) { const title = within(article as HTMLElement).getByRole("heading", { level: 3, }); expect(title.className).toContain("lg:min-h-[3.3em]"); expect(title.className).toContain("xl:min-h-[2.2em]"); } }); it("path: no text under 15px on the desktop either (the old 12 and 13px classes are gone)", () => { const { container } = render(); expect(container.innerHTML).not.toMatch( /(?:^|[\s:!"'])(?:md:|lg:)?!?text-\[(?:[0-9]|1[0-4])px\]|text-(?:xs|sm)\b/, ); expect(container.innerHTML).not.toContain("ds-kicker"); expect(container.innerHTML).not.toContain("md:!text-[13px]"); expect(container.innerHTML).not.toContain("md:!text-[12px]"); }); }); describe("WorkModalities: como eu trabalho", () => { it("path: the process is a compact list of four steps, with the title and the description in one line each", () => { const { container } = render(); const process = container.querySelector("#process") as HTMLElement; expect( within(process).getByRole("heading", { level: 3, name: "Como eu trabalho", }), ).toBeInTheDocument(); const steps = within(process).getAllByRole("listitem"); expect(steps).toHaveLength(4); expect( steps.map((step) => step.querySelector("strong")?.textContent), ).toEqual([ "Entender a operação.", "Definir a entrega.", "Construir e validar.", "Publicar e dar continuidade.", ]); // sem a grade de quatro cartões com subtítulo por etapa expect(process.querySelector("h4")).toBeNull(); expect(process.textContent).toMatch( /Prazo e investimento dependem do escopo/, ); }); }); describe("WorkModalities: sem prometer o que o dono não disse", () => { it("path: no price, deadline or contract promise anywhere in the section", () => { const { container } = render(); expect(container.textContent).not.toMatch( /R\$|em contrato|prazo de \d|\d+ dias|grátis|gratuit/i, ); }); }); describe("WorkModalities: a faixa da nota fiscal", () => { afterEach(() => { jest.useRealTimers(); }); it("path: up to 2027-03-31 a quiet line above the cards (no card, no colored box) says the Simples Nacional date and links to the page", () => { jest.useFakeTimers({ now: new Date("2027-03-31T23:59:00-03:00") }); const { container } = render(); const text = screen.getByText( /Os optantes do Simples Nacional entram na reforma tributária em 1º de janeiro de 2027\./, ); expect(text.textContent).toBe( "Seu sistema emite nota fiscal? Os optantes do Simples Nacional entram na reforma tributária em 1º de janeiro de 2027.", ); expect( screen.getByRole("link", { name: "Ver o que muda" }), ).toHaveAttribute("href", "/servicos/nota-fiscal-reforma-tributaria"); const notice = text.parentElement as HTMLElement; expect(notice.closest("article")).toBeNull(); expect( notice.compareDocumentPosition( container.querySelector("article") as Element, ) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect(notice.className).not.toMatch(/bg-|border-l-|ds-card|shadow/); }); it("path: from 2027-04-01 on, no notice, and the four cards stay", () => { jest.useFakeTimers({ now: new Date("2027-04-01T00:00:00-03:00") }); const { container } = render(); expect(screen.queryByText(/Seu sistema emite nota fiscal\?/)).toBeNull(); expect(screen.queryByRole("link", { name: "Ver o que muda" })).toBeNull(); expect(container.querySelectorAll("article")).toHaveLength(4); }); });