From 62bcfbd8fa941b287045ce61483139e41fef58d1 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Fri, 4 Sep 2026 19:25:30 -0300 Subject: [PATCH] feat: harden portfolio and add SEO foundations --- .github/workflows/ci.yml | 26 + .gitignore | 1 + README.md | 170 +- __mocks__/resizeObserver.js | 8 - app/cases/[slug]/opengraph-image.tsx | 52 + app/cases/[slug]/page.test.tsx | 324 +++ app/cases/[slug]/page.tsx | 218 +- app/cases/page.tsx | 80 +- .../CompanyLogos.altText.pbt.test.tsx | 320 --- .../CompanyLogos.reducedMotion.pbt.test.tsx | 344 --- app/components/CompanyLogos.test.tsx | 503 ---- app/components/CompanyLogos.tsx | 116 - app/components/ContactForm.tsx | 911 ------- app/components/ContactInfo.tsx | 147 - .../Experience.categorization.pbt.test.tsx | 127 - ...Experience.chronologicalOrder.pbt.test.tsx | 146 - .../Experience.headingHierarchy.pbt.test.tsx | 132 - app/components/Experience.test.tsx | 127 + app/components/Experience.tsx | 8 +- app/components/Footer.ariaLabels.pbt.test.tsx | 300 -- .../Footer.responsiveLayout.pbt.test.tsx | 36 - .../Footer.socialLinks.pbt.test.tsx | 246 -- app/components/Footer.test.tsx | 13 +- app/components/GoogleAnalytics.test.tsx | 220 ++ app/components/GoogleAnalytics.tsx | 287 +- app/components/Header.tsx | 2 +- app/components/Hero.reducedMotion.test.tsx | 109 +- app/components/Hero.test.tsx | 75 +- app/components/Hero.tsx | 591 +--- app/components/Projects.test.tsx | 161 ++ app/components/Projects.tsx | 39 +- app/components/RouteTransition.tsx | 39 - app/components/SectionLoading.tsx | 10 +- app/components/WorkModalities.tsx | 15 +- .../animations/TextDiffuseReveal.tsx | 119 +- .../animations/__mocks__/PointCloud.tsx | 1 - .../cinematic/CinematicEffects.test.tsx | 103 + .../cinematic/CinematicHome.test.tsx | 50 +- app/components/cinematic/CinematicHome.tsx | 41 +- .../cinematic/CinematicOverlays.tsx | 4 - app/components/cinematic/CinematicSection.tsx | 173 -- app/components/cinematic/CinematicText.tsx | 119 - .../cinematic/ControlRoomGlobe.test.tsx | 146 + app/components/cinematic/ControlRoomGlobe.tsx | 84 +- app/components/cinematic/CustomCursor.tsx | 159 -- app/components/cinematic/FilmGrain.tsx | 69 +- app/components/cinematic/FloatingElements.tsx | 178 -- app/components/cinematic/HeroScene.tsx | 11 - app/components/cinematic/HomeButton.tsx | 66 - app/components/cinematic/HorizontalScroll.tsx | 108 - app/components/cinematic/MagneticButton.tsx | 4 - app/components/cinematic/PageIntro.test.tsx | 53 - app/components/cinematic/PageIntro.tsx | 107 - .../cinematic/ScrollVelocityText.tsx | 131 +- app/components/cinematic/SectionDivider.tsx | 104 +- app/components/story/NotebookBrowser.test.tsx | 60 +- app/components/story/NotebookBrowser.tsx | 27 +- .../story/NotebookFallback.test.tsx | 4 +- app/components/story/NotebookStage3D.test.tsx | 4 +- app/components/story/NotebookStory.test.tsx | 87 +- app/components/story/NotebookStory.tsx | 27 +- app/components/story/ReelDirector.tsx | 149 - app/components/story/notebookProjects.test.ts | 65 +- app/components/story/notebookProjects.ts | 17 +- app/curriculo/page.test.tsx | 49 + app/curriculo/page.tsx | 64 +- app/globals.css | 243 +- app/hooks/useScrollReveal.ts | 56 - app/layout.tsx | 216 +- app/lib/animations/config.ts | 70 - app/lib/animations/variants.ts | 98 - app/opengraph-image.tsx | 49 +- app/page.tsx | 6 +- app/providers/AnimationProvider.test.tsx | 148 - app/providers/AnimationProvider.tsx | 93 - app/providers/SmoothScrollProvider.tsx | 67 - app/servicos/[slug]/opengraph-image.tsx | 48 + app/servicos/[slug]/page.test.tsx | 251 ++ app/servicos/[slug]/page.tsx | 90 +- app/servicos/page.tsx | 86 +- app/sitemap.ts | 44 +- components.json | 21 - components/DotGrid.tsx | 305 --- components/FaultyTerminal.tsx | 446 --- components/LogoLoop.tsx | 443 --- components/RotatingText.tsx | 44 - components/Squares.tsx | 158 -- components/TextType.tsx | 209 -- components/magicui/animated-gradient-text.tsx | 36 - components/magicui/aurora-text.tsx | 33 - components/magicui/bento-grid.tsx | 54 - components/magicui/dock.tsx | 153 -- components/magicui/dot-pattern.tsx | 159 -- components/magicui/globe.tsx | 128 - components/magicui/magic-card.tsx | 25 - components/magicui/shimmer-button.tsx | 31 - components/magicui/text-reveal.tsx | 23 - docs/project-health-cron.md | 23 - docs/project-health.md | 39 + .../plans/2026-09-04-portfolio-hardening.md | 903 ++++++ .../plans/2026-09-04-seo-geo-growth.md | 765 ++++++ .../2026-09-04-portfolio-hardening-design.md | 267 ++ .../specs/2026-09-04-seo-geo-growth-design.md | 391 +++ jest.setup.ts | 10 +- lib/og-card.tsx | 74 + lib/portfolio-data.test.ts | 81 +- lib/project-health.test.ts | 64 +- lib/project-health.ts | 56 +- lib/seo.test.ts | 121 + lib/seo.ts | 83 + lib/site-content.test.ts | 100 + lib/site-content.ts | 318 ++- lib/usePortfolioData.ts | 99 - lib/utils.ts | 2 +- next.config.ts | 81 +- nixpacks.toml | 2 +- package.json | 71 +- pnpm-lock.yaml | 2416 +++++++++-------- pnpm-workspace.yaml | 39 - public/portfolio-data.json | 86 +- tailwind.config.js | 28 +- 121 files changed, 7769 insertions(+), 10469 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 __mocks__/resizeObserver.js create mode 100644 app/cases/[slug]/opengraph-image.tsx create mode 100644 app/cases/[slug]/page.test.tsx delete mode 100644 app/components/CompanyLogos.altText.pbt.test.tsx delete mode 100644 app/components/CompanyLogos.reducedMotion.pbt.test.tsx delete mode 100644 app/components/CompanyLogos.test.tsx delete mode 100644 app/components/CompanyLogos.tsx delete mode 100644 app/components/ContactForm.tsx delete mode 100644 app/components/ContactInfo.tsx delete mode 100644 app/components/Experience.categorization.pbt.test.tsx delete mode 100644 app/components/Experience.chronologicalOrder.pbt.test.tsx delete mode 100644 app/components/Experience.headingHierarchy.pbt.test.tsx create mode 100644 app/components/Experience.test.tsx delete mode 100644 app/components/Footer.ariaLabels.pbt.test.tsx delete mode 100644 app/components/Footer.responsiveLayout.pbt.test.tsx delete mode 100644 app/components/Footer.socialLinks.pbt.test.tsx create mode 100644 app/components/GoogleAnalytics.test.tsx create mode 100644 app/components/Projects.test.tsx delete mode 100644 app/components/RouteTransition.tsx create mode 100644 app/components/cinematic/CinematicEffects.test.tsx delete mode 100644 app/components/cinematic/CinematicSection.tsx delete mode 100644 app/components/cinematic/CinematicText.tsx create mode 100644 app/components/cinematic/ControlRoomGlobe.test.tsx delete mode 100644 app/components/cinematic/CustomCursor.tsx delete mode 100644 app/components/cinematic/FloatingElements.tsx delete mode 100644 app/components/cinematic/HomeButton.tsx delete mode 100644 app/components/cinematic/HorizontalScroll.tsx delete mode 100644 app/components/cinematic/PageIntro.test.tsx delete mode 100644 app/components/cinematic/PageIntro.tsx delete mode 100644 app/components/story/ReelDirector.tsx create mode 100644 app/curriculo/page.test.tsx delete mode 100644 app/hooks/useScrollReveal.ts delete mode 100644 app/lib/animations/config.ts delete mode 100644 app/lib/animations/variants.ts delete mode 100644 app/providers/AnimationProvider.test.tsx delete mode 100644 app/providers/AnimationProvider.tsx delete mode 100644 app/providers/SmoothScrollProvider.tsx create mode 100644 app/servicos/[slug]/opengraph-image.tsx create mode 100644 app/servicos/[slug]/page.test.tsx delete mode 100644 components.json delete mode 100644 components/DotGrid.tsx delete mode 100644 components/FaultyTerminal.tsx delete mode 100644 components/LogoLoop.tsx delete mode 100644 components/RotatingText.tsx delete mode 100644 components/Squares.tsx delete mode 100644 components/TextType.tsx delete mode 100644 components/magicui/animated-gradient-text.tsx delete mode 100644 components/magicui/aurora-text.tsx delete mode 100644 components/magicui/bento-grid.tsx delete mode 100644 components/magicui/dock.tsx delete mode 100644 components/magicui/dot-pattern.tsx delete mode 100644 components/magicui/globe.tsx delete mode 100644 components/magicui/magic-card.tsx delete mode 100644 components/magicui/shimmer-button.tsx delete mode 100644 components/magicui/text-reveal.tsx delete mode 100644 docs/project-health-cron.md create mode 100644 docs/project-health.md create mode 100644 docs/superpowers/plans/2026-09-04-portfolio-hardening.md create mode 100644 docs/superpowers/plans/2026-09-04-seo-geo-growth.md create mode 100644 docs/superpowers/specs/2026-09-04-portfolio-hardening-design.md create mode 100644 docs/superpowers/specs/2026-09-04-seo-geo-growth-design.md create mode 100644 lib/og-card.tsx create mode 100644 lib/seo.test.ts create mode 100644 lib/seo.ts create mode 100644 lib/site-content.test.ts delete mode 100644 lib/usePortfolioData.ts delete mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4156f39 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +"on": + push: + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test -- --runInBand + - run: pnpm build + - run: pnpm audit --prod --audit-level=high + - run: pnpm audit --dev --audit-level=high diff --git a/.gitignore b/.gitignore index 7726ffb..a0d7728 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ next-env.d.ts .claude/settings.local.json .superpowers/ /.worktrees/ +/.impeccable/ # generated analysis artifacts /graphify-out/ diff --git a/README.md b/README.md index 8ae8c4f..2c0d4a3 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,132 @@
-# Evandro Dev — Portfolio +# Evandro.dev.br: portfólio -**A cinematic, interactive developer portfolio built with Next.js, React, TypeScript, and motion-first UI engineering.** - -![Next.js](https://img.shields.io/badge/Next.js-15-000?logo=nextdotjs) -![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=111) -![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=fff) -![Biome](https://img.shields.io/badge/Code%20Style-Biome-60A5FA) -![Jest](https://img.shields.io/badge/Tests-Jest-C21325?logo=jest&logoColor=fff) +Portfólio profissional de Evandro Fonseca Junior, com projetos, cases, serviços, currículo e contato por WhatsApp.
-## Overview +## Requisitos -This is the portfolio site for **Evandro Dev**. It presents professional work, case studies, technical positioning, and interactive visual storytelling through a high-polish Next.js interface. +- Node.js `22.x` +- pnpm `9.15.9`, fixado em `package.json#packageManager` -The project combines a modern App Router codebase with motion design, 3D/visual effects, and tested UI components. +O projeto usa Next.js `16.3.0`. Essa versão é intencional: `16.2.11` ainda mantinha três advisories transitivos de severidade alta; `16.3.0` foi a menor versão estável da mesma major que deixou as auditorias atuais sem vulnerabilidades high. -## Highlights - -| Area | Details | -| --- | --- | -| Framework | Next.js App Router with React and TypeScript. | -| Motion | GSAP, Framer Motion, Lenis, and custom animation components. | -| Visuals | Three.js / React Three Fiber / Drei for interactive and cinematic surfaces. | -| Styling | Tailwind CSS, utility composition, and reusable component primitives. | -| Quality | Jest, Testing Library, property-based tests, and Biome checks. | -| Content | Portfolio sections, case pages, company logos, hero composition, and supporting docs. | - -## Project structure - -```txt -app/ - page.tsx Main portfolio route - layout.tsx Root layout and metadata - components/ Portfolio sections and cinematic UI modules - cases/ Case-study routes/content - api/ Server/API routes -components/ Shared UI primitives -lib/ Utilities and shared logic -docs/ Supporting documentation -graphify-out/ Project analysis output -public/ Static assets -``` - -## Getting started +## Início rápido ```bash -pnpm install -pnpm dev +corepack pnpm@9.15.9 install --frozen-lockfile +corepack pnpm@9.15.9 dev ``` -The development server uses Turbopack through the project script. +O servidor de desenvolvimento usa Turbopack. Para executar o artefato de produção: + +```bash +corepack pnpm@9.15.9 build +corepack pnpm@9.15.9 start +``` ## Scripts -| Command | Purpose | +| Comando | Comportamento | | --- | --- | -| `pnpm dev` | Start the Next.js development server. | -| `pnpm build` | Build the production application. | -| `pnpm start` | Start the production server after building. | -| `pnpm lint` | Run Biome checks. | -| `pnpm format` | Format the codebase with Biome. | -| `pnpm test` | Run Jest tests. | -| `pnpm test:watch` | Run Jest in watch mode. | +| `pnpm dev` | Inicia o Next.js em desenvolvimento com Turbopack. | +| `pnpm build` | Gera o build de produção com Turbopack. | +| `pnpm start` | Serve um build já gerado. | +| `pnpm lint` | Executa `biome check`. | +| `pnpm typecheck` | Executa TypeScript sem emitir arquivos. | +| `pnpm format` | Formata o repositório com Biome. | +| `pnpm test` | Executa a suíte Jest. | +| `pnpm test:watch` | Executa Jest em modo watch. | +| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. | -## Engineering standards +## Stack -- Keep visual sections modular and testable. -- Respect reduced-motion and accessibility expectations for animated sections. -- Keep portfolio copy and case-study content easy to update without touching low-level animation code. -- Validate interactive components with tests when changing behavior, layout assumptions, or accessibility states. +| Área | Pacotes usados | +| --- | --- | +| Aplicação | Next.js 16.3.0, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. | +| Estilos e composição | Tailwind CSS 4, `clsx` e `tailwind-merge`. | +| Movimento | Motion 12 e GSAP 3. | +| 3D e gráficos | Three.js, React Three Fiber, Drei e Cobe. | +| Interface | Lucide React. | +| Qualidade | Biome, Jest, Testing Library e ambiente JSDOM. | -## Validation +Todas as dependências diretas estão fixadas em versões exatas no manifesto; o lockfile é instalado sem atualização implícita. -Before publishing changes: +## Rotas -```bash -pnpm lint -pnpm test -pnpm build +| Rota | Conteúdo | +| --- | --- | +| `/` | Homepage com hero, notebook de projetos, perfil, competências, experiência, projetos, modalidades de trabalho e contato. | +| `/cases` | Índice dos seis cases. | +| `/cases/nutriscan` | Case NutriScan. | +| `/cases/twins-lab` | Case Twins Lab. | +| `/cases/reddit-explorer` | Case Reddit Posts Explorer. | +| `/cases/sc-plus` | Case SC Plus Aquicultura. | +| `/cases/gcc-installer` | Case MinGW GCC Installer. | +| `/cases/as-servicos` | Case AS Serviços. | +| `/servicos` | Índice dos seis serviços. | +| `/servicos/criacao-de-sites` | Criação de sites. | +| `/servicos/engenharia-de-software` | Engenharia de software. | +| `/servicos/consultoria-nextjs` | Consultoria Next.js. | +| `/servicos/tauri-rust` | Desenvolvimento Tauri e Rust. | +| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT. | +| `/servicos/arquitetura-de-software` | Arquitetura de software. | +| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. | + +O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.svg` e `/favicon.ico`. Caminhos desconhecidos usam a página 404 do projeto. Não há API route ativa. + +## Comportamento da aplicação + +### Conteúdo e project health + +O conteúdo principal vem de `public/portfolio-data.json` e `lib/site-content.ts`. A seleção da homepage usa o campo `featured`; o snapshot de disponibilidade não remove cards. Ele apenas controla o estado do CTA de cada demo: disponível, indisponível, desconhecido ou sem demo. + +Em produção, a renderização só lê o snapshot versionado e não consulta demos externas. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é atualizado em memória. A atualização manual opcional é `pnpm check:projects` e grava o arquivo por rename atômico. O contrato completo está em [`docs/project-health.md`](docs/project-health.md). + +### 3D progressivo e movimento reduzido + +O hero 3D é carregado no cliente a partir de `1024px`, sem `prefers-reduced-motion: reduce` e sem ponteiro coarse. O notebook 3D exige mais de `1024px`, hover disponível e as mesmas condições de movimento e ponteiro. Nos demais casos, o notebook entrega a navegação HTML completa sem WebGL ou movimento. No notebook 3D, o loop de renderização só permanece ativo enquanto a seção cruza o viewport e a página está visível. + +### Contato + +O formulário da homepage valida os campos nativos de nome, e-mail, tipo e contexto. Ao enviar, ele abre `wa.me` em uma nova aba com o briefing preenchido; não existe backend de formulário. O rodapé mantém contato direto por e-mail. + +### Consentimento de analytics + +Google Analytics só é carregado depois de aceite explícito. A decisão fica em `evandro.analytics-consent.v1`; recusar não monta o loader. As preferências permanecem acessíveis e a revogação envia consentimento negado, ativa a flag de desabilitação, remove o loader e expira apenas cookies `_ga`/`_ga_*` acessíveis. + +## Estrutura + +```text +app/ App Router, rotas e componentes da interface +lib/ Dados derivados, navegação, JSON-LD e project health +public/ Conteúdo JSON, PDF, fontes, imagens e modelo 3D +scripts/check-projects.mjs + Atualização manual do snapshot de demos +data/project-health.json + Última observação versionada das demos +docs/project-health.md Contrato operacional do snapshot +.github/workflows/ci.yml + Gate único de integração contínua +nixpacks.toml Build e start para Nixpacks ``` -## License +## CI e deploy -Personal portfolio project unless a repository license says otherwise. +O workflow único em `.github/workflows/ci.yml` roda em cada `push` e `pull_request` com permissão `contents: read`. Ele instala Node 22 e pnpm conforme `packageManager`, restaura o cache do pnpm e executa, nesta ordem: frozen install, lint, typecheck, Jest serial, build e auditorias de produção e desenvolvimento com limite high. + +O Nixpacks usa `nodejs_22`, `pnpm-9_x`, `pnpm install --frozen-lockfile`, `pnpm run build` e `pnpm run start`. Não há etapa de deploy nem secret de CI neste repositório. + +## Validação local + +```bash +corepack pnpm@9.15.9 install --frozen-lockfile +corepack pnpm@9.15.9 lint +corepack pnpm@9.15.9 typecheck +corepack pnpm@9.15.9 test -- --runInBand +corepack pnpm@9.15.9 build +corepack pnpm@9.15.9 audit --prod --audit-level=high +corepack pnpm@9.15.9 audit --dev --audit-level=high +``` diff --git a/__mocks__/resizeObserver.js b/__mocks__/resizeObserver.js deleted file mode 100644 index f619fc0..0000000 --- a/__mocks__/resizeObserver.js +++ /dev/null @@ -1,8 +0,0 @@ -class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - -window.ResizeObserver = ResizeObserver; -module.exports = ResizeObserver; diff --git a/app/cases/[slug]/opengraph-image.tsx b/app/cases/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..1b703da --- /dev/null +++ b/app/cases/[slug]/opengraph-image.tsx @@ -0,0 +1,52 @@ +import { notFound } from "next/navigation"; +import { ImageResponse } from "next/og"; +import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card"; +import { caseStudies } from "@/lib/site-content"; + +function getCaseStudy(slug: string) { + return caseStudies.find(({ id }) => id === slug); +} + +function getImageMetadata(caseStudy: (typeof caseStudies)[number]) { + return { + id: caseStudy.id, + alt: `Case ${caseStudy.title} — Evandro Fonseca Junior`, + size: OG_SIZE, + contentType: OG_CONTENT_TYPE, + }; +} + +export function generateImageMetadata({ + params, +}: { + params: { slug?: string }; +}) { + if (!params.slug) { + return caseStudies.map(getImageMetadata); + } + + const caseStudy = getCaseStudy(params.slug); + if (!caseStudy) notFound(); + return [getImageMetadata(caseStudy)]; +} + +export default async function Image({ + params, + id, +}: { + params: Promise<{ slug: string }>; + id: Promise; +}) { + const [{ slug }, imageId] = await Promise.all([params, id]); + const caseStudy = getCaseStudy(slug); + if (!caseStudy || imageId !== caseStudy.id) notFound(); + + return new ImageResponse( + , + OG_SIZE, + ); +} diff --git a/app/cases/[slug]/page.test.tsx b/app/cases/[slug]/page.test.tsx new file mode 100644 index 0000000..b683ae0 --- /dev/null +++ b/app/cases/[slug]/page.test.tsx @@ -0,0 +1,324 @@ +import { render, screen, within } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { caseStudies, servicePages, siteUrl } from "@/lib/site-content"; +import CasesPage from "../page"; +import CasePage, { generateMetadata, generateStaticParams } from "./page"; + +jest.mock("next/navigation", () => ({ notFound: jest.fn() })); + +jest.mock("@/app/components/cinematic/HeroBackground", () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock("@/app/components/cinematic/MagneticButton", () => ({ + __esModule: true, + default: ({ + as = "button", + strength: _strength, + children, + ...props + }: { + as?: string; + strength?: number; + children?: ReactNode; + } & Record) => { + const React = require("react"); + return React.createElement(as, props, children); + }, +})); + +const publicCases = [ + { id: "nutriscan", title: "NutriScan" }, + { id: "twins-lab", title: "Twins Lab" }, + { id: "reddit-explorer", title: "Reddit Posts Explorer" }, + { id: "sc-plus", title: "SC Plus Aquicultura" }, + { id: "gcc-installer", title: "MinGW GCC Installer" }, + { id: "as-servicos", title: "AS Serviços" }, +] as const; +const publicCaseIds = publicCases.map(({ id }) => id); + +function getCase(id: (typeof publicCases)[number]["id"]) { + const caseStudy = caseStudies.find((item) => item.id === id); + if (!caseStudy) throw new Error(`Missing case ${id}`); + return caseStudy; +} + +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]; + }); +} + +function expectCaseCtas() { + expect( + screen.getByRole("link", { + name: "Avaliar perfil para contratação", + }), + ).toHaveAttribute("href", "/curriculo"); + expect( + screen.getByRole("link", { + name: "Solicitar consultoria técnica", + }), + ).toHaveAttribute("href", "/#contact"); +} + +it("keeps the six public params and factual case metadata", async () => { + const staticSlugs = generateStaticParams().map(({ slug }) => slug); + expect(staticSlugs).toHaveLength(6); + expect(new Set(staticSlugs).size).toBe(6); + expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort()); + + const caseStudy = getCase("as-servicos"); + const url = `${siteUrl}/cases/${caseStudy.id}`; + const metadata = await generateMetadata({ + params: Promise.resolve({ slug: caseStudy.id }), + }); + expect(metadata).toMatchObject({ + title: expect.stringContaining(caseStudy.title), + description: caseStudy.summary, + alternates: { canonical: url }, + openGraph: { + title: caseStudy.title, + description: caseStudy.summary, + url, + }, + twitter: { + title: caseStudy.title, + description: caseStudy.summary, + }, + }); + expect(metadata).not.toHaveProperty("keywords"); +}); + +it("renders the authorized detailed case in order with traceable sources", async () => { + const caseStudy = getCase("as-servicos"); + if (!caseStudy.detail) throw new Error("Missing detailed case content"); + const ui = await CasePage({ + params: Promise.resolve({ slug: caseStudy.id }), + }); + const { container } = render(ui); + + const heading = screen.getByRole("heading", { level: 1 }); + const answer = screen.getByText(caseStudy.detail.answer.text); + expect(heading.nextElementSibling).toBe(answer); + + const sectionNames = [ + "Desafio verificável", + "Decisões de implementação", + "Limites desta evidência", + "Fontes", + ]; + const sectionHeadings = sectionNames.map((name) => + screen.getByRole("heading", { level: 2, name }), + ); + for (let index = 1; index < sectionHeadings.length; index += 1) { + expect( + sectionHeadings[index - 1].compareDocumentPosition( + sectionHeadings[index], + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } + + const challengeSection = screen + .getByRole("heading", { level: 2, name: "Desafio verificável" }) + .closest("section"); + if (!challengeSection) throw new Error("Missing challenge section"); + const challengeText = within(challengeSection).getByText( + caseStudy.detail.challenge.text, + ); + expect(challengeText).toBeVisible(); + expect(screen.getAllByText(caseStudy.impact)).toEqual([challengeText]); + for (const statement of caseStudy.detail.implementation) { + expect(screen.getByText(statement.text)).toBeVisible(); + } + for (const boundary of caseStudy.detail.boundaries) { + const boundaryText = screen.getByText(boundary.text); + expect(boundaryText).toBeVisible(); + expect(boundaryText.closest("a")).toBeNull(); + } + + const sourceLinks = screen.getAllByRole("link", { name: /^Fonte:/ }); + expect(sourceLinks).toHaveLength(caseStudy.detail.sources.length); + for (const source of caseStudy.detail.sources) { + const link = screen.getByRole("link", { name: `Fonte: ${source.label}` }); + expect(link).toHaveAttribute("href", source.url); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", expect.stringMatching(/noreferrer/)); + expect(source.url).toMatch(/\/blob\/[0-9a-f]{40}\//); + const sha = source.url.match(/\/blob\/([0-9a-f]{40})\//)?.[1]; + if (!sha) throw new Error(`Missing source SHA for ${source.id}`); + expect(within(link.closest("li") ?? link).getByText(sha)).toBeVisible(); + } + + expectCaseCtas(); + + const graph = readJsonLdEntities(container); + expect(graph.map((node) => node["@type"])).toEqual(["BreadcrumbList"]); + expect(JSON.stringify(graph)).not.toMatch( + /Article|FAQPage|ProfessionalService|LocalBusiness/, + ); + const breadcrumb = graph[0] 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: "Breadcrumb", + }); + 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("keeps the fallback concise and links its declared services", async () => { + const caseStudy = getCase("gcc-installer"); + expect(caseStudy.detail).toBeUndefined(); + const ui = await CasePage({ + params: Promise.resolve({ slug: caseStudy.id }), + }); + const { container } = render(ui); + + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary); + expect(screen.getByText(caseStudy.impact)).toBeVisible(); + for (const technology of caseStudy.stack) { + expect(screen.getByText(technology)).toBeVisible(); + } + expect(screen.queryByRole("link", { name: "Abrir demonstração" })).toBeNull(); + expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute( + "href", + caseStudy.githubUrl, + ); + 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(); + + const expectedServices = servicePages + .filter(({ relatedCaseIds }) => relatedCaseIds.includes(caseStudy.id)) + .map(({ slug, navLabel }) => ({ + name: `Ver serviço relacionado: ${navLabel}`, + href: `/servicos/${slug}`, + })) + .sort((a, b) => a.href.localeCompare(b.href)); + const relatedLinks = screen.getAllByRole("link", { + name: /^Ver serviço relacionado:/, + }); + expect( + relatedLinks + .map((link) => ({ + name: link.textContent, + href: link.getAttribute("href"), + })) + .sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")), + ).toEqual(expectedServices); + expectCaseCtas(); + + expect(readJsonLdEntities(container).map((node) => node["@type"])).toEqual([ + "BreadcrumbList", + ]); +}); + +it("keeps the hub cards, schema and distinct intents aligned", () => { + const { container } = render(); + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading.nextElementSibling).toHaveTextContent(/seis projetos/i); + + const caseLinks = screen.getAllByRole("link", { name: "Ler case" }); + const caseHrefs = caseLinks.map((link) => link.getAttribute("href")); + expect(caseHrefs).toHaveLength(6); + expect(new Set(caseHrefs).size).toBe(6); + expect([...caseHrefs].sort()).toEqual( + publicCaseIds.map((id) => `/cases/${id}`).sort(), + ); + expectCaseCtas(); + + const visibleBreadcrumb = screen.getByRole("navigation", { + name: "Breadcrumb", + }); + 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 = caseLinks.map((link, index) => { + const href = link.getAttribute("href"); + const expected = publicCases.find(({ id }) => href === `/cases/${id}`); + if (!expected) throw new Error(`Unexpected visible case link ${href}`); + const card = link.closest("article"); + if (!card) throw new Error(`Missing visible card for ${href}`); + expect(within(card).getByText(expected.title)).toBeVisible(); + return { + "@type": "ListItem", + position: index + 1, + name: expected.title, + url: `${siteUrl}${href}`, + }; + }); + expect(itemList.itemListElement).toEqual(visibleItems); +}); diff --git a/app/cases/[slug]/page.tsx b/app/cases/[slug]/page.tsx index 6dec415..409310a 100644 --- a/app/cases/[slug]/page.tsx +++ b/app/cases/[slug]/page.tsx @@ -2,12 +2,25 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import { safeJsonLd } from "@/lib/jsonld"; -import { caseStudies, siteName, siteUrl } from "@/lib/site-content"; +import { buildBreadcrumbList } from "@/lib/seo"; +import { + caseStudies, + getRelatedServices, + intentCtas, + siteName, + siteUrl, +} from "@/lib/site-content"; interface CasePageProps { params: Promise<{ slug: string }>; } +const sourceShaPattern = /\/blob\/([0-9a-f]{40})\//; + +function getSourceSha(url: string) { + return sourceShaPattern.exec(url)?.[1]; +} + export function generateStaticParams() { return caseStudies.map((caseStudy) => ({ slug: caseStudy.id })); } @@ -45,36 +58,16 @@ export default async function CasePage({ params }: CasePageProps) { const caseStudy = caseStudies.find((item) => item.id === slug); if (!caseStudy) notFound(); - const url = `${siteUrl}/cases/${caseStudy.id}`; + const pageUrl = `${siteUrl}/cases/${caseStudy.id}`; + const relatedServices = getRelatedServices(caseStudy.id); const jsonLd = { "@context": "https://schema.org", "@graph": [ - { - "@type": "Article", - headline: caseStudy.title, - description: caseStudy.summary, - mainEntityOfPage: url, - author: { "@id": `${siteUrl}/#person` }, - about: caseStudy.stack, - }, - { - "@type": "BreadcrumbList", - itemListElement: [ - { "@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: url, - }, - ], - }, + buildBreadcrumbList(pageUrl, [ + ["Início", siteUrl], + ["Cases", `${siteUrl}/cases`], + [caseStudy.title, pageUrl], + ] as const), ], }; @@ -84,18 +77,52 @@ export default async function CasePage({ params }: CasePageProps) {
+

Case de engenharia de software

{caseStudy.title}: {caseStudy.subtitle}

- {caseStudy.summary} -

-

- {caseStudy.impact} + {caseStudy.detail?.answer.text ?? caseStudy.summary}

+ {!caseStudy.detail ? ( +

+ {caseStudy.impact} +

+ ) : null} +
    + {caseStudy.stack.map((item) => ( +
  • + {item} +
  • + ))} +
- {caseStudy.demoUrl && ( + {caseStudy.demoUrl ? ( Abrir demonstração - )} - {caseStudy.githubUrl && ( + ) : null} + {caseStudy.githubUrl ? ( Ver código - )} + ) : null}
-
+ + {caseStudy.detail ? ( +
+
+
+

+ Desafio verificável +

+

+ {caseStudy.detail.challenge.text} +

+
+ +
+

+ Decisões de implementação +

+
    + {caseStudy.detail.implementation.map((statement) => ( +
  • {statement.text}
  • + ))} +
+
+ +
+

+ Limites desta evidência +

+
    + {caseStudy.detail.boundaries.map((boundary) => ( +
  • {boundary.text}
  • + ))} +
+
+ +
+

+ Fontes +

+
    + {caseStudy.detail.sources.map((source) => { + const sha = getSourceSha(source.url); + return ( +
  • + + Fonte: {source.label} + + {sha ? ( + + Commit: {sha} + + ) : null} +
  • + ); + })} +
+
+
+
+ ) : null} + + {relatedServices.length > 0 ? ( +
+
+

Serviços relacionados

+
+ {relatedServices.map((service) => ( + + Ver serviço relacionado: {service.navLabel} + + ))} +
+
+
+ ) : null} + +
-

- Tecnologias e contexto técnico -

-

- Este case demonstra como aplico engenharia de software a produtos - com requisitos de performance, integração e operação, para - empresas do Norte de Santa Catarina, Curitiba e atuação remota. -

-
    - {caseStudy.stack.map((item) => ( -
  • - {item} -
  • - ))} -
-
- - Ver currículo profissional +

Próximos caminhos

+
+ + {intentCtas.hiring.label} - - Ver serviços de engenharia + + {intentCtas.consulting.label}
diff --git a/app/cases/page.tsx b/app/cases/page.tsx index f052a95..6c1f196 100644 --- a/app/cases/page.tsx +++ b/app/cases/page.tsx @@ -3,20 +3,24 @@ import Link from "next/link"; import HeroBackground from "@/app/components/cinematic/HeroBackground"; import MagneticButton from "@/app/components/cinematic/MagneticButton"; import { safeJsonLd } from "@/lib/jsonld"; -import { caseStudies, siteName, siteUrl } from "@/lib/site-content"; +import { buildBreadcrumbList } from "@/lib/seo"; +import { caseStudies, intentCtas, siteName, siteUrl } from "@/lib/site-content"; + +const pageUrl = `${siteUrl}/cases`; +const pageTitle = "Cases de Engenharia de Software, Next.js e IoT"; +const pageDescription = + "Seis projetos mostram aplicações de engenharia de software em web, desktop, IoT, integrações e experiências orientadas a dados."; export const metadata: Metadata = { - title: "Cases de Engenharia de Software, Next.js e IoT", - description: - "Cases selecionados em engenharia de software, Next.js, IoT, integrações e software para contextos críticos. Portfolio técnico com foco em clareza, performance e execução.", + title: pageTitle, + description: pageDescription, alternates: { - canonical: `${siteUrl}/cases`, + canonical: pageUrl, }, openGraph: { - title: "Cases de Engenharia de Software, Next.js e IoT", - description: - "Cases selecionados em engenharia de software, Next.js, IoT, integrações e software para contextos críticos.", - url: `${siteUrl}/cases`, + title: pageTitle, + description: pageDescription, + url: pageUrl, siteName, type: "website", }, @@ -28,11 +32,10 @@ export default function CasesPage() { "@graph": [ { "@type": "CollectionPage", - "@id": `${siteUrl}/cases#page`, - name: "Cases de engenharia de software", - url: `${siteUrl}/cases`, - description: - "Coleção de cases em Next.js, IoT, integrações e software para operações críticas.", + "@id": `${pageUrl}#page`, + name: pageTitle, + url: pageUrl, + description: pageDescription, }, { "@type": "ItemList", @@ -40,21 +43,13 @@ export default function CasesPage() { "@type": "ListItem", position: index + 1, name: caseStudy.title, - url: `${siteUrl}/cases/${caseStudy.id}`, + url: `${pageUrl}/${caseStudy.id}`, })), }, - { - "@type": "BreadcrumbList", - itemListElement: [ - { "@type": "ListItem", position: 1, name: "Início", item: siteUrl }, - { - "@type": "ListItem", - position: 2, - name: "Cases", - item: `${siteUrl}/cases`, - }, - ], - }, + buildBreadcrumbList(pageUrl, [ + ["Início", siteUrl], + ["Cases", pageUrl], + ] as const), ], }; @@ -66,14 +61,31 @@ export default function CasesPage() {
+

Selected work

Cases em software, Next.js, integrações e produtos conectados.

- Esta página organiza projetos com leitura mais indexável e - comercial, conectando stack, contexto técnico e tipo de problema - resolvido. + {pageDescription}

@@ -124,19 +136,19 @@ export default function CasesPage() {
- Ver serviços + {intentCtas.hiring.label} - Ir para contato + {intentCtas.consulting.label}
diff --git a/app/components/CompanyLogos.altText.pbt.test.tsx b/app/components/CompanyLogos.altText.pbt.test.tsx deleted file mode 100644 index c04cf91..0000000 --- a/app/components/CompanyLogos.altText.pbt.test.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import { render } from "@testing-library/react"; -import fc from "fast-check"; -import CompanyLogos from "./CompanyLogos"; - -// Mock motion/react with configurable useReducedMotion -let mockReducedMotion = false; - -jest.mock("motion/react", () => { - const actual = jest.requireActual("motion/react"); - return { - ...actual, - useReducedMotion: () => mockReducedMotion, - motion: { - div: ({ children, animate, transition, ...props }: any) => { - return
{children}
; - }, - }, - }; -}); - -describe("Property 10: Alt Text for Images", () => { - afterEach(() => { - mockReducedMotion = false; - }); - - it("should have non-empty alt attributes for all company logo images", () => { - /** - * **Validates: Requirements 6.2** - * - * Property: For any company logo image in the CompanyLogos component, - * it should have a non-empty alt attribute describing the company. - * This ensures accessibility for screen readers and assistive technologies. - */ - fc.assert( - fc.property( - fc.boolean(), // Random reduced motion preference - (shouldReduce) => { - mockReducedMotion = shouldReduce; - - const { container } = render(); - - // Find all logo images - const logoImages = container.querySelectorAll("img"); - - // Verify we found images - if (logoImages.length === 0) { - return false; - } - - // Check that all images have non-empty alt text - return Array.from(logoImages).every((img) => { - const alt = img.getAttribute("alt"); - return alt !== null && alt.trim().length > 0; - }); - }, - ), - { numRuns: 100 }, - ); - }); - - it("should have descriptive alt text that includes company name", () => { - /** - * **Validates: Requirements 6.2** - * - * Property: For any company logo image, the alt text should be descriptive - * and include identifying information about the company. This ensures - * meaningful accessibility information is provided. - */ - fc.assert( - fc.property( - fc.boolean(), // Random reduced motion preference - (shouldReduce) => { - mockReducedMotion = shouldReduce; - - const { container } = render(); - - // Find all logo images - const logoImages = container.querySelectorAll("img"); - - // Verify we found images - if (logoImages.length === 0) { - return false; - } - - // Check that all alt text contains "logo" or is descriptive - return Array.from(logoImages).every((img) => { - const alt = img.getAttribute("alt"); - if (!alt) return false; - - const altLower = alt.toLowerCase(); - // Alt text should contain "logo" or be at least 5 characters (descriptive) - return altLower.includes("logo") || alt.trim().length >= 5; - }); - }, - ), - { numRuns: 100 }, - ); - }); - - it("should maintain alt text consistency across multiple renders", () => { - /** - * **Validates: Requirements 6.2** - * - * Property: For any number of component renders, the alt text for each - * company logo should remain consistent. This ensures stable accessibility - * information across re-renders. - */ - fc.assert( - fc.property( - fc.integer({ min: 2, max: 5 }), // Test 2-5 renders - fc.boolean(), // Random reduced motion preference - (numRenders, shouldReduce) => { - mockReducedMotion = shouldReduce; - - let firstRenderAltTexts: string[] = []; - - for (let i = 0; i < numRenders; i++) { - const { container, unmount } = render(); - - const logoImages = container.querySelectorAll("img"); - const currentAltTexts = Array.from(logoImages).map( - (img) => img.getAttribute("alt") || "", - ); - - if (i === 0) { - firstRenderAltTexts = currentAltTexts; - } else { - // Compare with first render - if (currentAltTexts.length !== firstRenderAltTexts.length) { - unmount(); - return false; - } - - // Check that alt texts match (accounting for duplicates in animated mode) - const expectedCount = shouldReduce ? 5 : 10; - if (currentAltTexts.length !== expectedCount) { - unmount(); - return false; - } - } - - unmount(); - } - - return true; - }, - ), - { numRuns: 100 }, - ); - }); - - it("should have unique alt text for each distinct company", () => { - /** - * **Validates: Requirements 6.2** - * - * Property: For any set of company logos, each distinct company should have - * its own unique alt text. This ensures each company is properly identified - * for accessibility purposes. - */ - fc.assert( - fc.property(fc.constant(null), () => { - mockReducedMotion = true; // Use static mode to get unique logos - - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - const altTexts = Array.from(logoImages).map( - (img) => img.getAttribute("alt") || "", - ); - - // In static mode, we should have 5 unique alt texts - const uniqueAltTexts = new Set(altTexts); - - // Should have 5 unique alt texts (one per company) - return uniqueAltTexts.size === 5 && altTexts.length === 5; - }), - { numRuns: 100 }, - ); - }); - - it("should preserve alt text in both animated and static modes", () => { - /** - * **Validates: Requirements 6.2** - * - * Property: For any reduced motion preference, all company logo images - * should maintain their alt text attributes. The animation mode should - * not affect accessibility attributes. - */ - fc.assert( - fc.property( - fc.boolean(), // Random reduced motion preference - (shouldReduce) => { - mockReducedMotion = shouldReduce; - - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - // Verify all images have alt attributes - const allHaveAlt = Array.from(logoImages).every((img) => { - const alt = img.getAttribute("alt"); - return alt !== null && alt.trim().length > 0; - }); - - // Verify correct number of images based on mode - const expectedCount = shouldReduce ? 5 : 10; - const hasCorrectCount = logoImages.length === expectedCount; - - return allHaveAlt && hasCorrectCount; - }, - ), - { numRuns: 100 }, - ); - }); - - it("should not have empty or whitespace-only alt text", () => { - /** - * **Validates: Requirements 6.2** - * - * Property: For any company logo image, the alt text should not be empty - * or consist only of whitespace. This ensures meaningful accessibility - * information is always provided. - */ - fc.assert( - fc.property( - fc.boolean(), // Random reduced motion preference - (shouldReduce) => { - mockReducedMotion = shouldReduce; - - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - // Check that no image has empty or whitespace-only alt text - return Array.from(logoImages).every((img) => { - const alt = img.getAttribute("alt"); - - // Alt must exist and not be empty after trimming - if (!alt || alt.trim().length === 0) { - return false; - } - - // Alt should not be just whitespace - return alt !== alt.trim() ? false : true; - }); - }, - ), - { numRuns: 100 }, - ); - }); - - it("should have alt text that matches the expected company names", () => { - /** - * **Validates: Requirements 6.2** - * - * Property: For any company logo image, the alt text should correspond to - * one of the expected company names (Apolo Energia, Julia Beauty Lashes, - * Innovo, TeraByteShop, Pichau). This ensures correct identification. - */ - fc.assert( - fc.property(fc.constant(null), () => { - mockReducedMotion = true; // Use static mode for clarity - - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - const altTexts = Array.from(logoImages).map( - (img) => img.getAttribute("alt") || "", - ); - - // Expected company names in alt text - const expectedCompanies = [ - "Apolo Energia", - "Julia Beauty Lashes", - "Innovo", - "TeraByteShop", - "Pichau", - ]; - - // Check that each alt text contains one of the expected company names - return altTexts.every((alt) => - expectedCompanies.some((company) => alt.includes(company)), - ); - }), - { numRuns: 100 }, - ); - }); - - it("should maintain alt text when images fail to load", () => { - /** - * **Validates: Requirements 6.2** - * - * Property: For any company logo image that fails to load, the alt text - * should still be present in the DOM. This ensures accessibility is - * maintained even when images are unavailable. - */ - fc.assert( - fc.property( - fc.boolean(), // Random reduced motion preference - (shouldReduce) => { - mockReducedMotion = shouldReduce; - - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - // Verify all images have alt text before any error handling - const allHaveAltBeforeError = Array.from(logoImages).every((img) => { - const alt = img.getAttribute("alt"); - return alt !== null && alt.trim().length > 0; - }); - - // The alt attribute should be present regardless of load state - return allHaveAltBeforeError; - }, - ), - { numRuns: 100 }, - ); - }); -}); diff --git a/app/components/CompanyLogos.reducedMotion.pbt.test.tsx b/app/components/CompanyLogos.reducedMotion.pbt.test.tsx deleted file mode 100644 index f58a2cf..0000000 --- a/app/components/CompanyLogos.reducedMotion.pbt.test.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import { render } from "@testing-library/react"; -import fc from "fast-check"; -import CompanyLogos from "./CompanyLogos"; - -// Mock motion/react with configurable useReducedMotion -let mockReducedMotion = false; - -jest.mock("motion/react", () => { - const actual = jest.requireActual("motion/react"); - return { - ...actual, - useReducedMotion: () => mockReducedMotion, - motion: { - div: ({ children, animate, transition, ...props }: any) => { - // Capture animation props for testing - const animationProps = { - "data-animate": JSON.stringify(animate), - "data-transition": JSON.stringify(transition), - }; - return ( -
- {children} -
- ); - }, - }, - }; -}); - -describe("Property 4: Reduced Motion Respect", () => { - afterEach(() => { - mockReducedMotion = false; - }); - - it("should render static grid layout when reduced motion is enabled", () => { - /** - * **Validates: Requirements 3.6, 6.3** - * - * Property: For any reduced motion preference, when useReducedMotion() returns true, - * the CompanyLogos component should render a static grid layout without motion - * animations. This ensures accessibility compliance with prefers-reduced-motion. - */ - fc.assert( - fc.property( - fc.boolean(), // Random reduced motion preference - (shouldReduce) => { - mockReducedMotion = shouldReduce; - - const { container } = render(); - - // Check for animated motion.div with animation props - const animatedElements = container.querySelectorAll("[data-animate]"); - - // Check for static grid layout - const staticGrid = container.querySelector(".grid"); - - if (shouldReduce) { - // When reduced motion is enabled: - // - Should NOT have animated elements - // - Should have static grid layout - return animatedElements.length === 0 && staticGrid !== null; - } else { - // When reduced motion is disabled: - // - Should have animated elements - // - Should NOT have static grid layout - return animatedElements.length > 0 && staticGrid === null; - } - }, - ), - { numRuns: 100 }, - ); - }); - - it("should exclude horizontal scroll animation when reduced motion is enabled", () => { - /** - * **Validates: Requirements 3.6, 6.3** - * - * Property: For any component render with reduced motion enabled, the horizontal - * scroll animation (x: [0, '-50%']) should be completely absent. This verifies - * that motion animations are properly disabled. - */ - fc.assert( - fc.property( - fc.constant(null), // No random input needed - () => { - mockReducedMotion = true; - - const { container } = render(); - - // Check for animation props - const animatedElements = container.querySelectorAll("[data-animate]"); - - let hasScrollAnimation = false; - - animatedElements.forEach((element) => { - const animateValue = element.getAttribute("data-animate"); - if (animateValue && animateValue !== "undefined") { - try { - const props = JSON.parse(animateValue); - - // Check for x animation (horizontal scroll) - if (props.x !== undefined) { - hasScrollAnimation = true; - } - } catch (e) { - // Skip invalid JSON - } - } - }); - - // Should not have scroll animation when reduced motion is enabled - return !hasScrollAnimation; - }, - ), - { numRuns: 100 }, - ); - }); - - it("should display all company logos in both animated and static modes", () => { - /** - * **Validates: Requirements 3.6, 6.3** - * - * Property: For any reduced motion preference, all company logos should be - * displayed. The reduced motion preference should only affect the animation - * style, not the content visibility. - */ - fc.assert( - fc.property( - fc.boolean(), // Random reduced motion preference - (shouldReduce) => { - mockReducedMotion = shouldReduce; - - const { container } = render(); - - // Find all logo images - const logoImages = container.querySelectorAll('img[alt*="logo"]'); - - // Should have at least 5 logos (the original set) - // In animated mode, we have duplicates, so it will be 10 - // In static mode, we have exactly 5 - if (shouldReduce) { - return logoImages.length === 5; - } else { - return logoImages.length === 10; // Duplicated for infinite scroll - } - }, - ), - { numRuns: 100 }, - ); - }); - - it("should maintain accessibility attributes regardless of reduced motion preference", () => { - /** - * **Validates: Requirements 3.6, 6.3, 6.2** - * - * Property: For any reduced motion preference, all company logo images should - * maintain proper alt text attributes. Accessibility should not be compromised - * by the animation mode. - */ - fc.assert( - fc.property( - fc.boolean(), // Random reduced motion preference - (shouldReduce) => { - mockReducedMotion = shouldReduce; - - const { container } = render(); - - // Find all logo images - const logoImages = container.querySelectorAll("img"); - - // Verify we found images - if (logoImages.length === 0) { - return false; - } - - // Check that all images have non-empty alt text - return Array.from(logoImages).every((img) => { - const alt = img.getAttribute("alt"); - return alt !== null && alt.trim().length > 0; - }); - }, - ), - { numRuns: 100 }, - ); - }); - - it("should consistently apply reduced motion across multiple renders", () => { - /** - * **Validates: Requirements 3.6, 6.3** - * - * Property: For any number of component renders with reduced motion enabled, - * the static layout should be consistently rendered without animations. - * This verifies that the reduced motion behavior is stable across re-renders. - */ - fc.assert( - fc.property( - fc.integer({ min: 1, max: 5 }), // Test 1-5 renders - (numRenders) => { - mockReducedMotion = true; - - let allRendersValid = true; - - for (let i = 0; i < numRenders; i++) { - const { container, unmount } = render(); - - // Check for animated elements - const animatedElements = - container.querySelectorAll("[data-animate]"); - - // Check for static grid - const staticGrid = container.querySelector(".grid"); - - // Should have no animations and should have static grid - if (animatedElements.length > 0 || staticGrid === null) { - allRendersValid = false; - } - - unmount(); - } - - return allRendersValid; - }, - ), - { numRuns: 100 }, - ); - }); - - it("should use grid layout classes when reduced motion is enabled", () => { - /** - * **Validates: Requirements 3.6, 6.3** - * - * Property: When reduced motion is enabled, the component should use CSS grid - * layout classes (grid, grid-cols-*) to display logos in a static arrangement. - * This ensures proper visual presentation without animation. - */ - fc.assert( - fc.property(fc.constant(null), () => { - mockReducedMotion = true; - - const { container } = render(); - - // Check for grid layout classes - const gridContainer = container.querySelector(".grid"); - - if (!gridContainer) { - return false; - } - - // Verify grid has responsive column classes - const hasGridCols = gridContainer.className.includes("grid-cols-"); - - return hasGridCols; - }), - { numRuns: 100 }, - ); - }); - - it("should not have infinite repeat transition when reduced motion is enabled", () => { - /** - * **Validates: Requirements 3.6, 6.3** - * - * Property: When reduced motion is enabled, the component should not have - * transition configurations with infinite repeat. This ensures no continuous - * motion occurs when users prefer reduced motion. - */ - fc.assert( - fc.property(fc.constant(null), () => { - mockReducedMotion = true; - - const { container } = render(); - - // Check for transition props - const elementsWithTransition = - container.querySelectorAll("[data-transition]"); - - let hasInfiniteRepeat = false; - - elementsWithTransition.forEach((element) => { - const transitionValue = element.getAttribute("data-transition"); - if (transitionValue && transitionValue !== "undefined") { - try { - const props = JSON.parse(transitionValue); - - // Check for infinite repeat - if (props.repeat === Infinity || props.repeat === "Infinity") { - hasInfiniteRepeat = true; - } - } catch (e) { - // Skip invalid JSON - } - } - }); - - // Should not have infinite repeat when reduced motion is enabled - return !hasInfiniteRepeat; - }), - { numRuns: 100 }, - ); - }); - - it("should switch between animated and static modes based on reduced motion preference", () => { - /** - * **Validates: Requirements 3.6, 6.3** - * - * Property: The component should render different layouts based on the reduced - * motion preference. When the preference changes, the component should adapt - * accordingly. This verifies the conditional rendering logic. - */ - fc.assert( - fc.property( - fc.array(fc.boolean(), { minLength: 2, maxLength: 5 }), // Sequence of preference changes - (preferenceSequence) => { - let allTransitionsValid = true; - - preferenceSequence.forEach((shouldReduce) => { - mockReducedMotion = shouldReduce; - - const { container, unmount } = render(); - - const hasAnimations = - container.querySelectorAll("[data-animate]").length > 0; - const hasStaticGrid = container.querySelector(".grid") !== null; - - // Verify correct mode is rendered - if (shouldReduce) { - if (hasAnimations || !hasStaticGrid) { - allTransitionsValid = false; - } - } else { - if (!hasAnimations || hasStaticGrid) { - allTransitionsValid = false; - } - } - - unmount(); - }); - - return allTransitionsValid; - }, - ), - { numRuns: 100 }, - ); - }); -}); diff --git a/app/components/CompanyLogos.test.tsx b/app/components/CompanyLogos.test.tsx deleted file mode 100644 index ab65046..0000000 --- a/app/components/CompanyLogos.test.tsx +++ /dev/null @@ -1,503 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import CompanyLogos from "./CompanyLogos"; - -// Mock motion/react with configurable useReducedMotion -let mockReducedMotion = false; - -jest.mock("motion/react", () => { - const actual = jest.requireActual("motion/react"); - return { - ...actual, - useReducedMotion: () => mockReducedMotion, - motion: { - div: ({ children, animate, transition, ...props }: any) => { - // Capture animation props for testing - const animationProps = { - "data-animate": JSON.stringify(animate), - "data-transition": JSON.stringify(transition), - }; - return ( -
- {children} -
- ); - }, - }, - }; -}); - -describe("CompanyLogos Component - Logo Rendering", () => { - beforeEach(() => { - mockReducedMotion = false; - }); - - it("should render all five company logos", () => { - const { container } = render(); - - // In animated mode, logos are duplicated (10 total) - const logoImages = container.querySelectorAll('img[alt*="logo"]'); - expect(logoImages.length).toBe(10); // 5 logos x 2 (duplicated for infinite scroll) - }); - - it("should render Apolo Energia logo", () => { - render(); - - const apoloLogos = screen.getAllByAltText("Apolo Energia logo"); - expect(apoloLogos.length).toBe(2); // Duplicated for infinite scroll - expect(apoloLogos[0]).toHaveAttribute("src", "/logos/apolo.svg"); - }); - - it("should render Julia Beauty Lashes logo", () => { - render(); - - const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo"); - expect(juliaLogos.length).toBe(2); - expect(juliaLogos[0]).toHaveAttribute("src", "/logos/julia.svg"); - }); - - it("should render Innovo logo", () => { - render(); - - const innovoLogos = screen.getAllByAltText("Innovo logo"); - expect(innovoLogos.length).toBe(2); - expect(innovoLogos[0]).toHaveAttribute("src", "/logos/innovo.svg"); - }); - - it("should render TeraByteShop logo", () => { - render(); - - const terabyteLogos = screen.getAllByAltText("TeraByteShop logo"); - expect(terabyteLogos.length).toBe(2); - expect(terabyteLogos[0]).toHaveAttribute("src", "/logos/terabyte.svg"); - }); - - it("should render Pichau logo", () => { - render(); - - const pichauLogos = screen.getAllByAltText("Pichau logo"); - expect(pichauLogos.length).toBe(2); - expect(pichauLogos[0]).toHaveAttribute("src", "/logos/pichau.svg"); - }); - - it("should have alt text for all logo images", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - logoImages.forEach((img) => { - const alt = img.getAttribute("alt"); - expect(alt).toBeTruthy(); - expect(alt?.length).toBeGreaterThan(0); - }); - }); - - it("should duplicate logo array for seamless infinite scroll", () => { - const { container } = render(); - - // Each company should appear exactly twice - const apoloLogos = screen.getAllByAltText("Apolo Energia logo"); - const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo"); - const innovoLogos = screen.getAllByAltText("Innovo logo"); - const terabyteLogos = screen.getAllByAltText("TeraByteShop logo"); - const pichauLogos = screen.getAllByAltText("Pichau logo"); - - expect(apoloLogos.length).toBe(2); - expect(juliaLogos.length).toBe(2); - expect(innovoLogos.length).toBe(2); - expect(terabyteLogos.length).toBe(2); - expect(pichauLogos.length).toBe(2); - }); -}); - -describe("CompanyLogos Component - Animation Configuration", () => { - beforeEach(() => { - mockReducedMotion = false; - }); - - it("should have horizontal scroll animation configuration", () => { - const { container } = render(); - - const animatedElement = container.querySelector("[data-animate]"); - expect(animatedElement).toBeInTheDocument(); - - const animateValue = animatedElement?.getAttribute("data-animate"); - expect(animateValue).toBeTruthy(); - - const animateProps = JSON.parse(animateValue!); - expect(animateProps.x).toEqual([0, "-50%"]); - }); - - it("should have 30-second animation duration", () => { - const { container } = render(); - - const animatedElement = container.querySelector("[data-transition]"); - expect(animatedElement).toBeInTheDocument(); - - const transitionValue = animatedElement?.getAttribute("data-transition"); - expect(transitionValue).toBeTruthy(); - - const transitionProps = JSON.parse(transitionValue!); - expect(transitionProps.duration).toBe(30); - }); - - it("should have infinite repeat configuration", () => { - const { container } = render(); - - const animatedElement = container.querySelector("[data-transition]"); - const transitionValue = animatedElement?.getAttribute("data-transition"); - const transitionProps = JSON.parse(transitionValue!); - - // JSON.stringify converts Infinity to null, so we check for null - expect(transitionProps.repeat).toBe(null); - }); - - it("should use linear easing for smooth animation", () => { - const { container } = render(); - - const animatedElement = container.querySelector("[data-transition]"); - const transitionValue = animatedElement?.getAttribute("data-transition"); - const transitionProps = JSON.parse(transitionValue!); - - expect(transitionProps.ease).toBe("linear"); - }); - - it("should have flex layout with gap-sp-12 for logo spacing", () => { - const { container } = render(); - - const animatedElement = container.querySelector("[data-animate]"); - const classes = animatedElement?.className || ""; - - expect(classes).toContain("flex"); - expect(classes).toContain("gap-sp-12"); - }); - - it("should have items-center alignment", () => { - const { container } = render(); - - const animatedElement = container.querySelector("[data-animate]"); - const classes = animatedElement?.className || ""; - - expect(classes).toContain("items-center"); - }); -}); - -describe("CompanyLogos Component - Reduced Motion Fallback", () => { - beforeEach(() => { - mockReducedMotion = true; - }); - - afterEach(() => { - mockReducedMotion = false; - }); - - it("should render static grid when reduced motion is enabled", () => { - const { container } = render(); - - const staticGrid = container.querySelector(".grid"); - expect(staticGrid).toBeInTheDocument(); - }); - - it("should not have animated elements when reduced motion is enabled", () => { - const { container } = render(); - - const animatedElements = container.querySelectorAll("[data-animate]"); - expect(animatedElements.length).toBe(0); - }); - - it("should render exactly 5 logos in static mode (no duplication)", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll('img[alt*="logo"]'); - expect(logoImages.length).toBe(5); - }); - - it("should have responsive grid columns in static mode", () => { - const { container } = render(); - - const staticGrid = container.querySelector(".grid"); - const classes = staticGrid?.className || ""; - - expect(classes).toContain("grid-cols-2"); - expect(classes).toContain("sm:grid-cols-3"); - expect(classes).toContain("md:grid-cols-5"); - }); - - it("should have gap-sp-6 spacing in static grid", () => { - const { container } = render(); - - const staticGrid = container.querySelector(".grid"); - const classes = staticGrid?.className || ""; - - expect(classes).toContain("gap-sp-6"); - }); - - it("should maintain all logo alt text in static mode", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - logoImages.forEach((img) => { - const alt = img.getAttribute("alt"); - expect(alt).toBeTruthy(); - expect(alt?.length).toBeGreaterThan(0); - }); - }); -}); - -describe("CompanyLogos Component - Missing Logo Image Handling", () => { - beforeEach(() => { - mockReducedMotion = false; - }); - - it("should have onError handler for logo images", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - logoImages.forEach((img) => { - // Check that the image element has an onError handler - // In React, this is attached as a prop, so we verify the element exists - expect(img).toBeInTheDocument(); - }); - }); - - it("should apply grayscale filter to logo images", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - logoImages.forEach((img) => { - const classes = img.className; - expect(classes).toContain("grayscale"); - }); - }); - - it("should have hover effect to remove grayscale", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - logoImages.forEach((img) => { - const classes = img.className; - expect(classes).toContain("hover:grayscale-0"); - }); - }); - - it("should have opacity transition on hover", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - logoImages.forEach((img) => { - const classes = img.className; - expect(classes).toContain("opacity-60"); - expect(classes).toContain("hover:opacity-100"); - }); - }); - - it("should have transition-all for smooth hover effects", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - logoImages.forEach((img) => { - const classes = img.className; - expect(classes).toContain("transition-all"); - }); - }); -}); - -describe("CompanyLogos Component - Responsive Layout", () => { - beforeEach(() => { - mockReducedMotion = false; - }); - - it("should have section element as container", () => { - const { container } = render(); - - const section = container.querySelector("section"); - expect(section).toBeInTheDocument(); - }); - - it("should have full width class", () => { - const { container } = render(); - - const section = container.querySelector("section"); - const classes = section?.className || ""; - - expect(classes).toContain("w-full"); - }); - - it("should have overflow-hidden for animation containment", () => { - const { container } = render(); - - const section = container.querySelector("section"); - const classes = section?.className || ""; - - expect(classes).toContain("overflow-hidden"); - }); - - it("should have border-b for visual separation", () => { - const { container } = render(); - - const section = container.querySelector("section"); - const classes = section?.className || ""; - - expect(classes).toContain("border-b"); - }); - - it("should have background color class", () => { - const { container } = render(); - - const section = container.querySelector("section"); - const classes = section?.className || ""; - - expect(classes).toContain("bg-[var(--paper)]"); - }); - - it("should have py-sp-8 vertical padding", () => { - const { container } = render(); - - // In animated mode, padding is on inner div - const innerDiv = container.querySelector(".py-sp-8"); - expect(innerDiv).toBeInTheDocument(); - }); - - it("should have responsive padding in static mode", () => { - mockReducedMotion = true; - const { container } = render(); - - const innerContainer = container.querySelector(".container"); - const classes = innerContainer?.className || ""; - - expect(classes).toContain("px-sp-6"); - expect(classes).toContain("py-sp-8"); - expect(classes).toContain("md:px-sp-10"); - expect(classes).toContain("lg:px-sp-16"); - }); - - it("should have mx-auto for centering in static mode", () => { - mockReducedMotion = true; - const { container } = render(); - - const innerContainer = container.querySelector(".container"); - const classes = innerContainer?.className || ""; - - expect(classes).toContain("mx-auto"); - }); -}); - -describe("CompanyLogos Component - Logo Sizing and Styling", () => { - beforeEach(() => { - mockReducedMotion = false; - }); - - it("should have h-12 height for all logos", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - logoImages.forEach((img) => { - const classes = img.className; - expect(classes).toContain("h-12"); - }); - }); - - it("should have w-auto width for aspect ratio preservation", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - logoImages.forEach((img) => { - const classes = img.className; - expect(classes).toContain("w-auto"); - }); - }); - - it("should have object-contain for proper image scaling", () => { - const { container } = render(); - - const logoImages = container.querySelectorAll("img"); - - logoImages.forEach((img) => { - const classes = img.className; - expect(classes).toContain("object-contain"); - }); - }); - - it("should have flex-shrink-0 on logo containers in animated mode", () => { - const { container } = render(); - - const logoContainers = container.querySelectorAll("[data-animate] > div"); - - logoContainers.forEach((div) => { - const classes = div.className; - expect(classes).toContain("flex-shrink-0"); - }); - }); - - it("should center logos within their containers", () => { - const { container } = render(); - - const logoContainers = container.querySelectorAll("[data-animate] > div"); - - logoContainers.forEach((div) => { - const classes = div.className; - expect(classes).toContain("flex"); - expect(classes).toContain("items-center"); - expect(classes).toContain("justify-center"); - }); - }); -}); - -describe("CompanyLogos Component - Accessibility", () => { - beforeEach(() => { - mockReducedMotion = false; - }); - - it("should use semantic section element", () => { - const { container } = render(); - - const section = container.querySelector("section"); - expect(section).toBeInTheDocument(); - }); - - it("should have descriptive alt text for each logo", () => { - render(); - - expect(screen.getAllByAltText("Apolo Energia logo")).toHaveLength(2); - expect(screen.getAllByAltText("Julia Beauty Lashes logo")).toHaveLength(2); - expect(screen.getAllByAltText("Innovo logo")).toHaveLength(2); - expect(screen.getAllByAltText("TeraByteShop logo")).toHaveLength(2); - expect(screen.getAllByAltText("Pichau logo")).toHaveLength(2); - }); - - it("should respect reduced motion preference", () => { - mockReducedMotion = true; - const { container } = render(); - - // Should render static grid instead of animation - const staticGrid = container.querySelector(".grid"); - const animatedElements = container.querySelectorAll("[data-animate]"); - - expect(staticGrid).toBeInTheDocument(); - expect(animatedElements.length).toBe(0); - }); - - it("should maintain content visibility in both modes", () => { - // Test animated mode - mockReducedMotion = false; - const { container: animatedContainer } = render(); - const animatedLogos = - animatedContainer.querySelectorAll('img[alt*="logo"]'); - - // Test static mode - mockReducedMotion = true; - const { container: staticContainer } = render(); - const staticLogos = staticContainer.querySelectorAll('img[alt*="logo"]'); - - // Both modes should display logos (different counts due to duplication) - expect(animatedLogos.length).toBeGreaterThan(0); - expect(staticLogos.length).toBeGreaterThan(0); - }); -}); diff --git a/app/components/CompanyLogos.tsx b/app/components/CompanyLogos.tsx deleted file mode 100644 index 6f22bc4..0000000 --- a/app/components/CompanyLogos.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -/** - * CompanyLogos Component - * - * Displays an infinite-scrolling banner of company logos after the header. - * Features smooth horizontal animation with reduced motion support. - * - * Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8 - */ - -import { motion } from "motion/react"; -import Image from "next/image"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; - -// TypeScript interface for company logos -export interface CompanyLogo { - name: string; // Company name - imagePath: string; // Path to logo image (SVG preferred) - alt: string; // Alt text for accessibility -} - -// Company logos data -const companies: CompanyLogo[] = [ - { - name: "Apolo Energia", - imagePath: "/logos/apolo.svg", - alt: "Apolo Energia logo", - }, - { - name: "Julia Beauty Lashes", - imagePath: "/logos/julia.svg", - alt: "Julia Beauty Lashes logo", - }, - { - name: "Innovo", - imagePath: "/logos/innovo.svg", - alt: "Innovo logo", - }, - { - name: "TeraByteShop", - imagePath: "/logos/terabyte.svg", - alt: "TeraByteShop logo", - }, - { - name: "Pichau", - imagePath: "/logos/pichau.svg", - alt: "Pichau logo", - }, -]; - -export default function CompanyLogos() { - const shouldReduceMotion = useReducedMotion(); - - // Duplicate logo array for seamless infinite scroll - const duplicatedLogos = [...companies, ...companies]; - - // Static grid for reduced motion preference - if (shouldReduceMotion) { - return ( -
-
-
- {companies.map((logo) => ( -
- {logo.alt} -
- ))} -
-
-
- ); - } - - // Animated infinite scroll - return ( -
-
- - {duplicatedLogos.map((logo, index) => ( -
= companies.length ? true : undefined} - > - {logo.alt} -
- ))} -
-
-
- ); -} diff --git a/app/components/ContactForm.tsx b/app/components/ContactForm.tsx deleted file mode 100644 index 171d5ba..0000000 --- a/app/components/ContactForm.tsx +++ /dev/null @@ -1,911 +0,0 @@ -"use client"; - -import { motion } from "motion/react"; -import { type ChangeEvent, type FormEvent, useEffect, useState } from "react"; -import { usePortfolioData } from "@/lib/usePortfolioData"; -import { buildWhatsAppUrl } from "@/lib/whatsapp"; - -interface ContactFormProps { - formType: "simple" | "advanced"; - setFormType: (type: "simple" | "advanced") => void; -} - -export function ContactForm({ formType, setFormType }: ContactFormProps) { - const { data } = usePortfolioData(); - const [contactMethod, setContactMethod] = useState<"whatsapp" | "email">( - "whatsapp", - ); - - // MENTORIA: Agrupamento de estado para reduzir complexidade e re-renders - const [formData, setFormData] = useState({ - userName: "", - userEmail: "", - userPhone: "", - userCompany: "", - howFound: "", - projectType: "", - otherProjectType: "", - desiredDeadline: "", - budgetValue: "", - technicalComplexity: "", - hasInternalTeam: "", - customMessage: "", - }); - - const [isDecisor, setIsDecisor] = useState(""); - const [projectApproved, setProjectApproved] = useState(""); - const [urgency, setUrgency] = useState(""); - const [preferredModality, setPreferredModality] = useState(""); - const [isExpanded, setIsExpanded] = useState(false); // Novo estado para controlar a expansão do formulário avançado - const [nameError, setNameError] = useState(null); - const [emailError, setEmailError] = useState(null); - const [phoneError, setPhoneError] = useState(null); - const [otherProjectTypeError, setOtherProjectTypeError] = useState< - string | null - >(null); // Novo estado de erro - const [honeypot, setHoneypot] = useState(""); // Estado para o campo honeypot - const [startTime, setStartTime] = useState(0); // Estado para o tempo de início do formulário - const [nameFocused, setNameFocused] = useState(false); - const [emailFocused, setEmailFocused] = useState(false); - const [phoneFocused, setPhoneFocused] = useState(false); - - useEffect(() => { - setStartTime(Date.now()); // Registra o tempo de carregamento do componente - }, []); - - if (!data) { - return null; // Ou um componente de carregamento/erro - } - - const getGreetingMessageWithUserName = () => { - const hour = new Date().getHours(); - const dayPart = - hour >= 8 && hour < 12 - ? "manhã" - : hour >= 12 && hour < 18 - ? "tarde" - : "noite"; - - return `Olá Evandro, espero que esta ${dayPart} esteja sendo produtiva. - -Meu nome é ${formData.userName} e entrei em contato pois vi seu portfólio e identifiquei que suas habilidades como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente. - -Gostaria de agendar uma conversa para discutirmos detalhes e entender como podemos trabalhar juntos de forma eficiente e produtiva. - -Fico no aguardo do seu contato para marcarmos um horário que seja conveniente para ambos. - -Atenciosamente, -${formData.userName}`; - }; - - const generateAdvancedMessage = () => { - if (formData.customMessage) return formData.customMessage; - - // Saudação profissional - let message = `Prezado Evandro,\n\n`; - - // Informações do contato - message += `Meu nome é ${formData.userName}`; - if (formData.userCompany) { - message += `, represento a empresa ${formData.userCompany}`; - } - message += `.\n`; - - if (formData.howFound) { - message += `Conheci seu trabalho através de ${formData.howFound}.\n`; - } - - message += `\n`; - - // Detalhes do projeto - message += `=== DETALHES DO PROJETO ===\n`; - message += `Tipo de projeto: `; - if (formData.projectType === "outro" && formData.otherProjectType) { - message += `${formData.otherProjectType}\n`; - } else if (formData.projectType) { - message += `${formData.projectType}\n`; - } else { - message += `Não especificado\n`; - } - - if (formData.desiredDeadline) { - message += `Prazo desejado: ${formData.desiredDeadline}\n`; - } - - if (formData.budgetValue) { - message += `Orçamento disponível: ${formData.budgetValue}\n`; - } - - if (formData.technicalComplexity) { - message += `Complexidade técnica estimada: ${formData.technicalComplexity}\n`; - } - - if (formData.hasInternalTeam) { - message += `Equipe técnica interna: ${ - formData.hasInternalTeam === "sim" - ? "Sim" - : formData.hasInternalTeam === "nao" - ? "Não" - : "Parcialmente" - }\n`; - } - - message += `\n`; - - // Contatos - message += `=== INFORMAÇÕES PARA CONTATO ===\n`; - if (formData.userEmail) { - message += `📧 Email: ${formData.userEmail}\n`; - } - if (formData.userPhone) { - message += `📱 Telefone/WhatsApp: ${formData.userPhone}\n`; - } - - message += `\n`; - - // Mensagem final - message += `Estou entrando em contato pois acredito que seus conhecimentos como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.\n\n`; - message += `Fico no aguardo do seu contato para discutirmos detalhes e definirmos os próximos passos.\n\n`; - message += `Atenciosamente,\n`; - message += `${formData.userName}`; - - return message; - }; - - const handleInputChange = - (field: keyof typeof formData) => - ( - e: ChangeEvent< - HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement - >, - ) => { - setFormData((prev) => ({ ...prev, [field]: e.target.value })); - - // Clear specific errors if the user starts typing - if (e.target.value.trim() !== "") { - if (field === "userName") setNameError(null); - if (field === "userEmail") setEmailError(null); - if (field === "userPhone") setPhoneError(null); - if (field === "otherProjectType") setOtherProjectTypeError(null); - } - }; - - const handleNameFocus = () => setNameFocused(true); - const handleEmailFocus = () => setEmailFocused(true); - const handlePhoneFocus = () => setPhoneFocused(true); - - const generateWhatsAppUrl = () => - buildWhatsAppUrl( - formType === "advanced" - ? generateAdvancedMessage() - : formData.customMessage || getGreetingMessageWithUserName(), - ); - - const generateEmailUrl = () => { - const subject = encodeURIComponent("Proposta de Projeto - Portfólio"); - const body = encodeURIComponent( - formType === "advanced" - ? generateAdvancedMessage() - : `Nome: ${formData.userName}\nEmail: ${formData.userEmail}\nTelefone: ${formData.userPhone}\nEmpresa/Posição: ${formData.userCompany}\nComo conheceu: ${formData.howFound}\n\nTipo de Projeto: ${formData.projectType}\nPrazo Desejado: ${formData.desiredDeadline}\nOrçamento Disponível: ${formData.budgetValue}\nComplexidade Técnica: ${formData.technicalComplexity}\nTem equipe interna: ${formData.hasInternalTeam}\n\nMensagem: ${ - formData.customMessage || getGreetingMessageWithUserName() - }`, - ); - return `mailto:${data.contact.email}?subject=${subject}&body=${body}`; - }; - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - - let hasError = false; - if (formData.userName.trim() === "") { - setNameError("Por favor, insira seu nome."); - hasError = true; - } else { - setNameError(null); - } - - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(formData.userEmail)) { - setEmailError("Por favor, insira um email válido."); - hasError = true; - } else { - setEmailError(null); - } - - if (formData.userPhone.trim() === "") { - setPhoneError("Por favor, insira seu telefone."); - hasError = true; - } else { - setPhoneError(null); - } - - if ( - formData.projectType === "outro" && - formData.otherProjectType.trim() === "" - ) { - setOtherProjectTypeError("Por favor, especifique o tipo de projeto."); - hasError = true; - } else { - setOtherProjectTypeError(null); - } - - // Validação Honeypot - if (honeypot) { - console.log("Honeypot preenchido, provável bot."); - return; // Ignora a submissão - } - - // UX FIX: Validação de tempo com feedback ou limite menor - const timeElapsed = Date.now() - startTime; - // Reduzido para 1000ms para evitar falsos positivos com autofill - if (timeElapsed < 1000) { - console.log("Formulário preenchido muito rápido, provável bot."); - // Opcional: Mostrar erro para humano real que digita muito rápido - // alert("Por favor, verifique os dados antes de enviar."); - return; // Ignora a submissão - } - - if (hasError) return; - - // Verificação de interação mínima - if (!nameFocused || !emailFocused || !phoneFocused) { - // MENTORIA: Isso pode bloquear usuários de Screen Readers ou Autofill agressivo. - // Considere remover ou tornar apenas um aviso no console. - console.warn( - "Campos principais não foram focados, verificando integridade.", - ); - } - - if (contactMethod === "whatsapp") { - window.open(generateWhatsAppUrl(), "_blank"); - } else { - window.open(generateEmailUrl(), "_blank"); - } - }; - - const getCtaMessage = () => { - if (formData.projectType === "consultoria") { - return "Agendar Consultoria Gratuita"; - } - if ( - formData.budgetValue && - parseFloat(formData.budgetValue.replace(/\D/g, "")) < 2000 - ) { - return "Discutir Soluções de Baixo Custo"; - } - if ( - formData.projectType && - formData.desiredDeadline && - formData.budgetValue - ) { - return "Receber Proposta Personalizada"; - } - return "Enviar Formulário"; - }; - - const getEstimatedValue = () => { - let hours = 0; - let hourlyRate = 55; // Taxa base R$ 55/h - - // Estimativa de horas baseada no tipo de projeto - switch (formData.projectType) { - case "web-app": - hours += 80; // Aplicação web completa - break; - case "landing-page": - hours += 16; // Landing page simples - break; - case "mvp": - hours += 40; // MVP básico - break; - case "iot-app": - hours += 120; // Aplicação IoT complexa - break; - case "integracao": - hours += 32; // Integração de sistemas - break; - case "manutencao": - hours += 8; // Manutenção pontual - break; - case "consultoria": - hours += 4; // Consultoria inicial - break; - default: - break; - } - - // Ajuste de horas baseado no prazo - switch (formData.desiredDeadline) { - case "1-3-weeks": - hours *= 1.3; // Urgência aumenta horas - break; - case "1-2-months": - hours *= 1.1; - break; - case "6-plus-months": - hours *= 0.9; // Prazo longo pode reduzir horas - break; - default: - break; - } - - // Ajuste de horas baseado na complexidade - switch (formData.technicalComplexity) { - case "alta": - hours *= 1.4; - hourlyRate = 65; // Taxa maior para alta complexidade - break; - case "muito-alta": - hours *= 1.7; - hourlyRate = 70; // Taxa máxima para muito alta complexidade - break; - case "media": - hourlyRate = 50; // Taxa média - break; - default: - break; - } - - if (formData.hasInternalTeam === "nao") { - hours *= 1.2; // Sem equipe interna = mais horas necessárias - } - - const totalValue = hours * hourlyRate; - - if (totalValue > 0) { - return `Estimativa: ${Math.round( - hours, - )}h × R$ ${hourlyRate}/h = R$ ${totalValue.toFixed(0)}`; - } - - return "Estimativa: Solicite orçamento personalizado"; - }; - - return ( - -

- Preencha o Formulário -

- -
- - -
- - {formType === "simple" ? ( -
- {/* Campo Honeypot */} -
- - setHoneypot(e.target.value)} - /> -
- - {/* Formulário Simples: Apenas Nome */} -
- - - {nameError && ( -

{nameError}

- )} -
- - {/* Botão de Contato para o formulário simples */} - - - -
- ) : ( -
- {/* Campo Honeypot */} -
- - setHoneypot(e.target.value)} - /> -
- - {/* Dados Básicos */} -
-

- Dados Básicos -

-
-
- - - {nameError && ( -

{nameError}

- )} -
-
- - - {emailError && ( -

{emailError}

- )} -
-
- - - {phoneError && ( -

{phoneError}

- )} -
-
- - -
-
- - -
-
-
- - {/* Tile de Expandir/Recolher */} -
setIsExpanded(!isExpanded)} - className="p-4 rounded-lg bg-white/5 border border-white/10 text-white cursor-pointer hover:bg-white/10 transition-colors duration-300 mb-6 flex justify-between items-center" - > -

- {isExpanded - ? "Detalhes do Projeto (Recolher)" - : "Detalhes do Projeto (Expandir)"} -

- - - -
- -
- {/* Conteúdo do Formulário Avançado */} - {/* Sobre o Projeto */} -
-

- Sobre o Projeto -

-
-
- - -
- {formData.projectType === "outro" && ( -
- - - {otherProjectTypeError && ( -

- {otherProjectTypeError} -

- )} -
- )} -
- - -
-
- - -
-
- - -
-
- - -
-
-
- - {/* Custom Message Input */} -
- - -
-
- - {/* Contact Button */} - - - - {formData.projectType && - formData.desiredDeadline && - formData.budgetValue && ( -

- {getEstimatedValue()} -

- )} -
- )} -
- ); -} diff --git a/app/components/ContactInfo.tsx b/app/components/ContactInfo.tsx deleted file mode 100644 index cd10946..0000000 --- a/app/components/ContactInfo.tsx +++ /dev/null @@ -1,147 +0,0 @@ -"use client"; - -import { Mail, MessageCircle } from "lucide-react"; -import { motion } from "motion/react"; -import { useEffect, useState } from "react"; -import { usePortfolioData } from "@/lib/usePortfolioData"; -import { getWhatsAppDisplayNumber } from "@/lib/whatsapp"; -import MagneticButton from "./cinematic/MagneticButton"; - -interface ContactInfoProps { - onWhatsAppClick: () => void; -} - -export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) { - const { data } = usePortfolioData(); - const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp"); - - useEffect(() => { - setWhatsAppDisplay(getWhatsAppDisplayNumber()); - }, []); - - if (!data) { - return null; // Ou um componente de carregamento/erro - } - - return ( - -
-

- Entre em Contato -

-
- -
- -
-
-

Email

-

{data.contact.email}

-
-
- - -
- -
-
-

WhatsApp

-

{whatsAppDisplay}

-
-
- - -
- - - -
-
-

LinkedIn

-

- {data.contact.linkedin.replace("https://www.", "")} -

-
-
- - -
- - - -
-
-

GitHub

-

- {data.contact.github.replace("https://github.com/", "")} -

-
-
-
-
- -
-

- Localização -

-

📍 {data.contact.location}

-

- Disponível para projetos remotos e trabalho presencial na região. -

-
- - {/* Timeline de Resposta (Placeholder) */} -
-

- Timeline de Resposta -

-

- Em breve, uma timeline detalhada do processo de contato e resposta. -

-
-
- ); -} diff --git a/app/components/Experience.categorization.pbt.test.tsx b/app/components/Experience.categorization.pbt.test.tsx deleted file mode 100644 index b44c9a0..0000000 --- a/app/components/Experience.categorization.pbt.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { render } from "@testing-library/react"; -import fc from "fast-check"; -import type { HTMLAttributes, ReactNode } from "react"; -import type { PortfolioExperience } from "@/lib/portfolio-data"; -import Experience from "./Experience"; - -type MotionProps = HTMLAttributes & { - initial?: unknown; - whileInView?: unknown; - viewport?: unknown; - variants?: unknown; - whileHover?: unknown; - whileTap?: unknown; - transition?: unknown; -}; - -type SectionMockProps = { - children?: ReactNode; - title?: string; - eyebrow?: string; - description?: string; - disableChildrenAnimation?: boolean; - id?: string; - className?: string; -}; - -jest.mock("motion/react", () => { - const createMotionElement = - (Tag: "div" | "article") => - ({ - children, - initial: _initial, - whileInView: _whileInView, - viewport: _viewport, - variants: _variants, - whileHover: _whileHover, - whileTap: _whileTap, - transition: _transition, - ...props - }: MotionProps & { children?: ReactNode }) => ( - {children} - ); - - return { - motion: { - div: createMotionElement("div"), - article: createMotionElement("article"), - }, - useReducedMotion: () => false, - }; -}); - -jest.mock("./Section", () => ({ - Section: ({ - children, - title, - eyebrow: _eyebrow, - description: _description, - disableChildrenAnimation: _disableChildrenAnimation, - ...props - }: SectionMockProps) => ( -
-

{title}

- {children} -
- ), -})); - -jest.mock("./animations/DrawLine", () => ({ - DrawLine: () => , -})); - -function makeExperience(index: number, featured: boolean): PortfolioExperience { - return { - id: `experience-${index}`, - title: `Experience ${index}`, - company: "Company", - period: `${2020 + index} - Present`, - location: "Remote", - type: index % 2 === 0 ? "Software" : "Hardware", - description: "Description", - achievements: [], - technologies: [], - featured, - }; -} - -describe("Experience featured filtering", () => { - it("renders exactly the featured experiences", () => { - fc.assert( - fc.property( - fc.array(fc.boolean(), { minLength: 1, maxLength: 8 }), - (featuredFlags) => { - const experiences = featuredFlags.map((featured, index) => - makeExperience(index, featured), - ); - const { container, unmount } = render( - , - ); - const articles = Array.from(container.querySelectorAll("article")); - const expectedCount = featuredFlags.filter(Boolean).length; - - expect(articles).toHaveLength(expectedCount); - const articleText = articles.map( - (article) => article.textContent ?? "", - ); - featuredFlags.forEach((featured, index) => { - const title = `Experience ${index}`; - expect(articleText.some((text) => text.includes(title))).toBe( - featured, - ); - }); - - unmount(); - }, - ), - { numRuns: 25 }, - ); - }); - - it("renders an empty experience list safely", () => { - const { container } = render(); - - expect(container.querySelector("section")).toBeInTheDocument(); - expect(container.querySelectorAll("article")).toHaveLength(0); - }); -}); diff --git a/app/components/Experience.chronologicalOrder.pbt.test.tsx b/app/components/Experience.chronologicalOrder.pbt.test.tsx deleted file mode 100644 index 33800ed..0000000 --- a/app/components/Experience.chronologicalOrder.pbt.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { render } from "@testing-library/react"; -import fc from "fast-check"; -import type { HTMLAttributes, ReactNode } from "react"; -import type { PortfolioExperience } from "@/lib/portfolio-data"; -import Experience from "./Experience"; - -type MotionProps = HTMLAttributes & { - initial?: unknown; - whileInView?: unknown; - viewport?: unknown; - variants?: unknown; - whileHover?: unknown; - whileTap?: unknown; - transition?: unknown; -}; - -type SectionMockProps = { - children?: ReactNode; - title?: string; - eyebrow?: string; - description?: string; - disableChildrenAnimation?: boolean; - id?: string; - className?: string; -}; - -jest.mock("motion/react", () => { - const createMotionElement = - (Tag: "div" | "article") => - ({ - children, - initial: _initial, - whileInView: _whileInView, - viewport: _viewport, - variants: _variants, - whileHover: _whileHover, - whileTap: _whileTap, - transition: _transition, - ...props - }: MotionProps & { children?: ReactNode }) => ( - {children} - ); - - return { - motion: { - div: createMotionElement("div"), - article: createMotionElement("article"), - }, - useReducedMotion: () => false, - }; -}); - -jest.mock("./Section", () => ({ - Section: ({ - children, - title, - eyebrow: _eyebrow, - description: _description, - disableChildrenAnimation: _disableChildrenAnimation, - ...props - }: SectionMockProps) => ( -
-

{title}

- {children} -
- ), -})); - -jest.mock("./animations/DrawLine", () => ({ - DrawLine: () => , -})); - -function makeExperience( - index: number, - year: number, - period = `${year} - Present`, -): PortfolioExperience { - return { - id: `experience-${index}`, - title: `Experience ${index}`, - company: "Company", - period, - location: "Remote", - type: "Software", - description: "Description", - achievements: [], - technologies: [], - featured: true, - }; -} - -describe("Experience chronological ordering", () => { - it("sorts featured experiences by the first year in descending order", () => { - fc.assert( - fc.property( - fc.array(fc.integer({ min: 1990, max: 2035 }), { - minLength: 1, - maxLength: 8, - }), - (years) => { - const experiences = years.map((year, index) => - makeExperience(index, year), - ); - const { container, unmount } = render( - , - ); - const renderedYears = Array.from( - container.querySelectorAll("article .ds-kicker"), - ).map((element) => Number.parseInt(element.textContent ?? "0", 10)); - const expectedExperiences = [...experiences].sort( - (a, b) => - Number.parseInt(b.period, 10) - Number.parseInt(a.period, 10), - ); - const expectedYears = expectedExperiences.map((experience) => - Number.parseInt(experience.period, 10), - ); - const renderedTitles = Array.from( - container.querySelectorAll("article h3"), - ).map((element) => element.textContent); - - expect(renderedYears).toEqual(expectedYears); - expect(renderedTitles).toEqual( - expectedExperiences.map((experience) => experience.title), - ); - unmount(); - }, - ), - { numRuns: 25 }, - ); - }); - - it("sorts a period range by its first year", () => { - const experiences = [ - makeExperience(1, 2022, "2022 - 2024"), - makeExperience(2, 2024, "2024 - Present"), - makeExperience(3, 2021, "2021 - 2023"), - ]; - const { container } = render(); - - expect( - Array.from(container.querySelectorAll("article .ds-kicker")).map( - (element) => element.textContent, - ), - ).toEqual(["2024 - Present", "2022 - 2024", "2021 - 2023"]); - }); -}); diff --git a/app/components/Experience.headingHierarchy.pbt.test.tsx b/app/components/Experience.headingHierarchy.pbt.test.tsx deleted file mode 100644 index 0eb4062..0000000 --- a/app/components/Experience.headingHierarchy.pbt.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { render } from "@testing-library/react"; -import fc from "fast-check"; -import type { HTMLAttributes, ReactNode } from "react"; -import type { PortfolioExperience } from "@/lib/portfolio-data"; -import Experience from "./Experience"; - -type MotionProps = HTMLAttributes & { - initial?: unknown; - whileInView?: unknown; - viewport?: unknown; - variants?: unknown; - whileHover?: unknown; - whileTap?: unknown; - transition?: unknown; -}; - -type SectionMockProps = { - children?: ReactNode; - title?: string; - eyebrow?: string; - description?: string; - disableChildrenAnimation?: boolean; - id?: string; - className?: string; -}; - -jest.mock("motion/react", () => { - const createMotionElement = - (Tag: "div" | "article") => - ({ - children, - initial: _initial, - whileInView: _whileInView, - viewport: _viewport, - variants: _variants, - whileHover: _whileHover, - whileTap: _whileTap, - transition: _transition, - ...props - }: MotionProps & { children?: ReactNode }) => ( - {children} - ); - - return { - motion: { - div: createMotionElement("div"), - article: createMotionElement("article"), - }, - useReducedMotion: () => false, - }; -}); - -jest.mock("./Section", () => ({ - Section: ({ - children, - title, - eyebrow: _eyebrow, - description: _description, - disableChildrenAnimation: _disableChildrenAnimation, - ...props - }: SectionMockProps) => ( -
-

{title}

- {children} -
- ), -})); - -jest.mock("./animations/DrawLine", () => ({ - DrawLine: () => , -})); - -function makeExperience(index: number): PortfolioExperience { - return { - id: `experience-${index}`, - title: `Experience ${index}`, - company: "Company", - period: `${2020 + index} - Present`, - location: "Remote", - type: index % 2 === 0 ? "Software" : "Hardware", - description: "Description", - achievements: [], - technologies: [], - featured: true, - }; -} - -describe("Experience heading hierarchy", () => { - it("renders one h2 section heading and h3 headings for featured cards", () => { - fc.assert( - fc.property(fc.integer({ min: 1, max: 8 }), (count) => { - const experiences = Array.from({ length: count }, (_, index) => - makeExperience(index), - ); - const { container, unmount } = render( - , - ); - - const headings = Array.from( - container.querySelectorAll("h1, h2, h3, h4, h5, h6"), - ); - - expect(headings[0]?.tagName).toBe("H2"); - expect(container.querySelector("section > h2")).toHaveTextContent( - "Experiência em engenharia de software e sistemas críticos", - ); - expect(container.querySelectorAll("section > h2")).toHaveLength(1); - expect(container.querySelectorAll("article h3")).toHaveLength(count); - expect( - new Set( - Array.from(container.querySelectorAll("article h3")).map( - (heading) => heading.textContent, - ), - ), - ).toEqual(new Set(experiences.map((experience) => experience.title))); - expect(headings.some((heading) => heading.tagName === "H4")).toBe( - false, - ); - - unmount(); - }), - { numRuns: 25 }, - ); - }); - - it("renders no card headings when no experiences are supplied", () => { - const { container } = render(); - - expect(container.querySelector("section > h2")).toBeInTheDocument(); - expect(container.querySelectorAll("article h3")).toHaveLength(0); - }); -}); diff --git a/app/components/Experience.test.tsx b/app/components/Experience.test.tsx new file mode 100644 index 0000000..7fe66d0 --- /dev/null +++ b/app/components/Experience.test.tsx @@ -0,0 +1,127 @@ +import { render, screen, within } from "@testing-library/react"; +import type { HTMLAttributes, ReactNode } from "react"; +import type { PortfolioExperience } from "@/lib/portfolio-data"; +import Experience from "./Experience"; + +type MotionProps = HTMLAttributes & { + initial?: unknown; + whileInView?: unknown; + viewport?: unknown; + variants?: unknown; + whileHover?: unknown; + whileTap?: unknown; + transition?: unknown; +}; + +jest.mock("motion/react", () => { + const createMotionElement = + (Tag: "div" | "article") => + ({ + children, + initial: _initial, + whileInView: _whileInView, + viewport: _viewport, + variants: _variants, + whileHover: _whileHover, + whileTap: _whileTap, + transition: _transition, + ...props + }: MotionProps & { children?: ReactNode }) => ( + {children} + ); + + return { + motion: { + div: createMotionElement("div"), + article: createMotionElement("article"), + }, + useReducedMotion: () => false, + }; +}); + +jest.mock("./Section", () => ({ + Section: ({ + children, + id, + title, + }: { + children?: ReactNode; + id?: string; + title?: string; + }) => ( +
+

{title}

+ {children} +
+ ), +})); + +jest.mock("./animations/DrawLine", () => ({ + DrawLine: () => null, +})); + +function experience({ + id, + title, + period, + featured, +}: { + id: string; + title: string; + period: string; + featured: boolean; +}): PortfolioExperience { + return { + id, + title, + company: "Empresa", + period, + location: "Remoto", + type: "Software", + description: "Descrição", + achievements: [], + technologies: [], + featured, + }; +} + +describe("Experience", () => { + it("renders only featured experiences from newest to oldest", () => { + render( + , + ); + + expect( + screen.queryByRole("heading", { name: "Oculta" }), + ).not.toBeInTheDocument(); + expect( + screen + .getAllByRole("article") + .map( + (article) => + within(article).getByRole("heading", { level: 3 }).textContent, + ), + ).toEqual(["Mais recente", "Mais antiga"]); + }); +}); diff --git a/app/components/Experience.tsx b/app/components/Experience.tsx index 24982c7..9204fac 100644 --- a/app/components/Experience.tsx +++ b/app/components/Experience.tsx @@ -79,7 +79,9 @@ export default function Experience({
{exp.period}
-

{exp.title}

+

+ {exp.title} +

{exp.company} • {exp.type}

@@ -87,7 +89,9 @@ export default function Experience({
{exp.location}
-

{exp.description}

+

+ {exp.description} +

    {exp.achievements?.map((achievement) => ( diff --git a/app/components/Footer.ariaLabels.pbt.test.tsx b/app/components/Footer.ariaLabels.pbt.test.tsx deleted file mode 100644 index 546e535..0000000 --- a/app/components/Footer.ariaLabels.pbt.test.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { render } from "@testing-library/react"; -import fc from "fast-check"; -import Footer from "./Footer"; - -describe("Property 9: ARIA Labels for Links", () => { - it("should have descriptive aria-label attributes for all social media links", () => { - /** - * **Validates: Requirements 6.1** - * - * Property: For any social media link in the Footer component, it should have - * a descriptive aria-label attribute that identifies the platform and action. - * This ensures accessibility for users with assistive technologies. - */ - fc.assert( - fc.property( - fc.constant(null), // No random input needed, testing static component - () => { - const { container } = render(