- Add Playwright (Chromium) for E2E tests with webServer auto-start - Add Vitest + @testing-library/react + coverage-v8 with 80% threshold - Add smoke E2E spec and jest-dom setup file - Update turbo.json with test:e2e task - Enforce 80% coverage rule in AGENTS.md (global testing policy) - Add full Testes section to docs/conventions/frontend.md - Add tasks.md for scenario-creator feature (14 tasks, T01–T14) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8.0 KiB
Frontend Conventions — apps/web
Stack
- Framework: React 18 + TypeScript
- Build: Vite 6
- Estado global: Zustand
- Tipos compartilhados:
@ymir/types - Workers: Web Workers nativos (ES modules)
Estrutura de Pastas
src/
├── features/ # Código organizado por domínio
│ ├── simulation/
│ │ ├── components/ # Componentes específicos da feature
│ │ ├── hooks/ # Hooks específicos da feature
│ │ └── store.ts # Zustand store da feature
│ └── vessels/
│ ├── components/
│ ├── hooks/
│ └── store.ts
├── components/ # Componentes genéricos reutilizáveis (sem domínio)
├── hooks/ # Hooks genéricos compartilhados
├── stores/ # Stores globais (cross-feature)
├── workers/ # Web Workers
├── lib/ # Utilitários puros (sem React)
└── main.tsx
Features são verticais autossuficientes: um componente de simulation/ não importa de vessels/ diretamente — dados compartilhados passam por store global.
Componentes
Componentes são burros: recebem dados via props e disparam ações via callbacks ou stores. Sem fetching, sem lógica de negócio, sem efeitos colaterais dentro de componentes.
// ✅ correto
function VesselCard({ vessel, onSelect }: VesselCardProps) {
return <div onClick={() => onSelect(vessel.id)}>{vessel.name}</div>
}
// ❌ errado — componente buscando dados
function VesselCard({ id }: { id: number }) {
const [vessel, setVessel] = useState(null)
useEffect(() => { fetch(`/vessels/${id}`).then(...) }, [id])
...
}
Lógica de busca de dados fica em custom hooks:
// hooks/use-vessel.ts
export function useVessel(id: number) {
const [vessel, setVessel] = useState<VesselDTO | null>(null)
useEffect(() => {
fetch(`/api/vessels/${id}`)
.then(r => r.json())
.then(setVessel)
}, [id])
return vessel
}
Estado
Regras:
| Tipo de estado | Onde fica |
|---|---|
| Estado de UI puro (modal aberto, hover, input temporário) | useState local |
| Estado de feature (simulação rodando, lista de vessels) | Zustand store da feature |
| Estado cross-feature | Zustand store global em stores/ |
| Cache de dados remotos | Custom hook com useState (ou SWR futuramente) |
Sem prop drilling além de 2 níveis — extrair para store. Sem Context API para estado de aplicação — usar Zustand.
Estrutura de store:
// features/simulation/store.ts
import { create } from 'zustand'
interface SimulationStore {
// estado
status: 'idle' | 'running' | 'error'
// ações
start: () => void
stop: () => void
}
export const useSimulationStore = create<SimulationStore>((set, get) => ({
status: 'idle',
start: () => set({ status: 'running' }),
stop: () => set({ status: 'idle' }),
}))
Custom Hooks
- Prefixo
useobrigatório - Co-localizados com a feature quando específicos
- Em
hooks/quando reutilizáveis entre features - Encapsulam lógica — componentes não fazem
useEffectcom fetch direto
// ✅ hook com responsabilidade clara
export function useVesselList(): { vessels: VesselDTO[]; loading: boolean } {
...
}
// ✅ hook de store — apenas re-exporta slice relevante
export function useSimulationStatus() {
return useSimulationStore(s => s.status)
}
Naming
| Coisa | Padrão | Exemplo |
|---|---|---|
| Arquivos de componente | PascalCase.tsx |
TelemetryPanel.tsx |
| Arquivos de hook | use-kebab-case.ts |
use-vessel-list.ts |
| Arquivos de store | store.ts (na feature) |
features/simulation/store.ts |
| Arquivos utilitários | kebab-case.ts |
format-number.ts |
| Componentes | PascalCase |
VesselCard |
| Hooks | use[Ação/Entidade] |
useVesselList, useSimulationStatus |
| Stores | use[Domain]Store |
useSimulationStore |
| Props types | [Componente]Props |
VesselCardProps |
TypeScript
import typeobrigatório para importações tipo-only- Sem
any— exceção: boundary com WASM (documentar com comentário) - Props sempre tipadas com interface ou type alias explícito — sem inline objects grandes
- Sem
!non-null assertion onde pode ser evitado - Sem
enum— usaras constou union types
// ✅
import type { VesselDTO } from '@ymir/types'
interface VesselCardProps {
vessel: VesselDTO
onSelect: (id: number) => void
}
// ❌
function VesselCard(props: any) { ... }
Organização de Arquivos de Componente
Para componentes simples: um arquivo .tsx.
Para componentes com lógica complexa: co-locar hook e tipos:
features/simulation/components/
├── TelemetryPanel.tsx # componente
├── TelemetryPanel.hook.ts # lógica extraída (quando > ~30 linhas)
└── TelemetryPanel.types.ts # tipos locais (quando extensos)
Regras Gerais
- Arquivo máximo: ~200 linhas — dividir se passar
- Sem barrel
index.tsdentro de features (importar direto do arquivo) - Barrel
index.tspermitido emcomponents/ehooks/globais - Sem
console.logem código de produção - Ordenação de imports:
externos → @ymir/* → features/ → components/ → relativos - Componentes funcionais apenas — sem class components
Testes
Obrigatoriedade
Toda feature de UI deve ter testes antes de ser considerada completa.
Cobertura mínima: 80% em linhas, funções, branches e statements.
O build CI falha se os thresholds não forem atingidos (pnpm test retorna exit 1).
Dois níveis obrigatórios
| Nível | Ferramenta | Onde | O que testar |
|---|---|---|---|
| Unit / Component | Vitest + Testing Library | src/**/*.test.{ts,tsx} |
Lógica pura, hooks, stores, componentes isolados |
| E2E / Fluxo | Playwright | e2e/**/*.spec.ts |
Happy path e fluxos críticos no browser real |
Convenções de testes unitários (Vitest)
// src/features/scenario-creator/store.test.ts
import { renderHook, act } from '@testing-library/react'
import { useScenarioStore } from './store'
describe('useScenarioStore', () => {
beforeEach(() => {
useScenarioStore.getState().reset()
})
it('adds a vessel to the scenario', () => {
const { result } = renderHook(() => useScenarioStore())
act(() => {
result.current.addVessel({ vesselId: 1, name: 'VLCC' })
})
expect(result.current.vessels).toHaveLength(1)
expect(result.current.vessels[0].vesselId).toBe(1)
})
})
- Arquivo de teste co-localizado com o arquivo testado:
Foo.tsx→Foo.test.tsx describeagrupa por módulo/componente;itdescreve comportamento observável- Mocks externos (Leaflet, fetch, WASM) em
src/test/mocks/ - Setup global em
src/test/setup.ts(já configurado) - Rodar:
pnpm --filter @ymir/web test
Convenções de testes E2E (Playwright)
// e2e/scenario-creator.spec.ts
import { test, expect } from '@playwright/test'
test('instrutor cria e inicia simulação', async ({ page }) => {
await page.goto('/')
await page.getByRole('combobox', { name: /área/i }).selectOption('baia_de_guanabara')
await page.getByRole('button', { name: /adicionar vessel/i }).click()
await page.getByRole('button', { name: /iniciar/i }).click()
await expect(page.getByText(/rodando/i)).toBeVisible()
})
- Usar
getByRole/getByText/getByLabel— nunca seletores CSS frágeis - Um arquivo por feature:
e2e/scenario-creator.spec.ts - Toda feature nova: ao menos um spec cobrindo o happy path
- Rodar:
pnpm --filter @ymir/web test:e2e - Config em
apps/web/playwright.config.ts(Chromium, webServer automático)
Comandos
pnpm --filter @ymir/web test # unit + coverage (CI gate)
pnpm --filter @ymir/web test:watch # modo watch (desenvolvimento)
pnpm --filter @ymir/web test:e2e # E2E headless
pnpm --filter @ymir/web test:e2e:ui # E2E com UI do Playwright