# Teste Visual das Telas Principais — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Construir um harness Playwright que dirige o app de dev (localhost:8082) num navegador real, exercita ~38 cenários (válidos e inválidos) nas telas de Profissional, Efetivo e Card, e gera um relatório markdown de checklist com screenshots nas falhas.

**Architecture:** Script Node ESM único (`tests/visual/run.mjs`) orquestra três módulos de cenários (`scenarios/*.mjs`), apoiados por `lib/driver.mjs` (navegador, login, POST direto, captura de diálogos), `lib/fixtures.mjs` (garante obra/líder/trabalhador via UII) e `lib/report.mjs` (agrega resultados → markdown + PNGs). Cenários que a UI intencionalmente impede (hora malformada, transição de status inválida, salvar card fora do rascunho) são testados via POST autenticado direto — negativos legítimos das guardas do servidor. O resto é dirigido pelo DOM real, incluindo o JS client-side do financeiro.

**Tech Stack:** Node 23 (host), Playwright + Chromium, o app Citrus (Yii2/PHP 8.2) rodando no Docker.

**Pré-requisitos de execução (sempre):**
- App no ar: `docker compose up -d` → http://localhost:8082 respondendo.
- **reCAPTCHA desativado** no ambiente: `recaptcha.secretKey` vazia/comentada em `config/secrets.php` (senão o headless é recusado no login). Restaurar ao final.
- **Credenciais via env**: o driver lê `CITRUS_USER`/`CITRUS_PASS` (padrão `admin`/`citrus@2026`, o seed). Neste ambiente a conta administrativa é `fabricio`; rode os comandos com `CITRUS_USER=fabricio CITRUS_PASS='…'`. Nunca hardcode a senha no código/plano.
- Cuidado com o **lockout**: 5 tentativas falhas com um login válido bloqueiam a conta por 15 min (`config/params.php`). Não chute senha.
- Rodar todos os comandos a partir de `tests/visual/` no host (Node 23), **não** dentro do container.

**Convenção de verificação:** este harness é integração contra app vivo — não há TDD unitário clássico. Cada task termina rodando o próprio harness contra o app e observando as linhas de resultado (✅ para caminho feliz, erro esperado capturado para inválidos). O app precisa estar no ar antes de cada verificação.

---

## File Structure

Tudo novo, isolado em `tests/visual/` (não versiona `node_modules`):

- Create: `tests/visual/package.json` — deps (playwright), `"type":"module"`.
- Create: `tests/visual/.gitignore` — ignora `node_modules/`, `assets/tmp/`.
- Create: `tests/visual/lib/driver.mjs` — launch, login, navegação, POST direto, captura de diálogos, asserções de texto.
- Create: `tests/visual/lib/report.mjs` — modelo de resultado + geração do markdown enxuto.
- Create: `tests/visual/lib/fixtures.mjs` — garante Obra (detecta) + Líder + Trabalhador (cria via UI).
- Create: `tests/visual/scenarios/profissional.mjs` — P1–P16.
- Create: `tests/visual/scenarios/efetivo.mjs` — E1–E11.
- Create: `tests/visual/scenarios/card.mjs` — C1–C11.
- Create: `tests/visual/run.mjs` — orquestrador + flags `--only=`.
- Create (gerado): `tests/visual/assets/sample.png` — arquivo de upload (1×1 PNG).
- Saída (gerada): `docs/superpowers/relatorios/2026-07-15-teste-visual-telas-principais.md` + `docs/superpowers/relatorios/assets/2026-07-15/*.png`.

Responsabilidade única por arquivo: driver não conhece cenários; report não conhece navegador; cada `scenarios/*.mjs` só descreve/dirige sua tela.

---

## Task 1: Scaffold do harness + login

**Files:**
- Create: `tests/visual/package.json`
- Create: `tests/visual/.gitignore`
- Create: `tests/visual/lib/driver.mjs`
- Create: `tests/visual/run.mjs` (versão mínima só p/ login)

- [ ] **Step 1: Criar `tests/visual/package.json`**

```json
{
  "name": "citrus-visual-tests",
  "private": true,
  "type": "module",
  "version": "1.0.0",
  "scripts": {
    "test": "node run.mjs"
  },
  "dependencies": {
    "playwright": "^1.48.0"
  }
}
```

- [ ] **Step 2: Criar `tests/visual/.gitignore`**

```
node_modules/
assets/tmp/
```

- [ ] **Step 3: Instalar Playwright + Chromium**

Run (a partir de `tests/visual/`):
```bash
cd tests/visual && npm install && npx playwright install chromium
```
Expected: instala o pacote e baixa o Chromium (download único). Sem erros no final.

- [ ] **Step 4: Criar `tests/visual/lib/driver.mjs`**

```javascript
import { chromium } from 'playwright';

export const BASE = process.env.CITRUS_BASE || 'http://localhost:8082';
export const USER = process.env.CITRUS_USER || 'admin';
export const PASS = process.env.CITRUS_PASS || 'citrus@2026';

/** Sobe o navegador com geolocalização concedida (o form de efetivo pede GPS). */
export async function launch() {
  const browser = await chromium.launch();
  const context = await browser.newContext({
    viewport: { width: 1280, height: 900 },
    geolocation: { latitude: -23.5505, longitude: -46.6333 },
    permissions: ['geolocation'],
  });
  const page = await context.newPage();
  return { browser, context, page };
}

/** Clica um submit que PODE navegar (POST→redirect) ou apenas validar no cliente.
 *  Espera a próxima navegação; se ela não vier (validação client-side bloqueou o submit),
 *  segue após o timeout curto. NÃO use `Promise.all([waitForLoadState, click])`: como a
 *  página atual já está carregada, o waitForLoadState resolve na hora e cria uma corrida
 *  (a checagem roda antes da navegação do submit terminar). */
export async function submitAndWait(page, selector, timeout = 6000) {
  const nav = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout }).catch(() => null);
  await page.click(selector);
  await nav;
}

/** Faz login pela UI. Requer o reCAPTCHA desativado no ambiente (secretKey vazia) e as
 *  credenciais em CITRUS_USER/CITRUS_PASS. */
export async function login(page) {
  await page.goto(`${BASE}/site/login`, { waitUntil: 'domcontentloaded' });
  await page.fill('input[name="LoginForm[login]"]', USER);
  await page.fill('input[name="LoginForm[password]"]', PASS);
  await submitAndWait(page, '#login-form button[type="submit"]');
  if (page.url().includes('/site/login')) {
    throw new Error('Login falhou — verifique CITRUS_USER/CITRUS_PASS, o reCAPTCHA (deve estar desativado) ou se o app está no ar.');
  }
}

/** Navega para uma rota Yii (pretty URLs) com querystring. */
export async function goto(page, path, query = {}) {
  const qs = new URLSearchParams(query).toString();
  const url = `${BASE}${path}${qs ? '?' + qs : ''}`;
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  return url;
}

/** Lê um token CSRF válido de qualquer form da página atual. */
export async function csrf(page) {
  const token = await page.getAttribute('input[name="_csrf"]', 'value');
  if (!token) throw new Error('CSRF token não encontrado na página atual.');
  return token;
}

/** POST autenticado direto (para negativos que a UI esconde). Segue redirects;
 *  retorna o HTML final (com a flash message). Requer uma página carregada p/ o CSRF. */
export async function postForm(page, path, fields) {
  const token = await csrf(page);
  const resp = await page.request.post(`${BASE}${path}`, { form: { ...fields, _csrf: token } });
  return await resp.text();
}

/** Instala captura de diálogos (alert/confirm). Aceita todos e registra a mensagem. */
export function captureDialogs(page) {
  const store = { messages: [] };
  page.on('dialog', async (d) => {
    store.messages.push(d.message());
    await d.accept().catch(() => {});
  });
  store.last = () => store.messages[store.messages.length - 1] || '';
  store.clear = () => { store.messages.length = 0; };
  return store;
}

/** Texto visível da página inteira (para asserções tolerantes a classe CSS). */
export async function bodyText(page) {
  return (await page.textContent('body')) || '';
}
```

- [ ] **Step 5: Criar `tests/visual/run.mjs` (mínimo — só valida login)**

```javascript
import { launch, login, BASE } from './lib/driver.mjs';

const { browser, page } = await launch();
try {
  console.log(`Login em ${BASE} ...`);
  await login(page);
  console.log('OK — logado. URL atual:', page.url());
} finally {
  await browser.close();
}
```

- [ ] **Step 6: Verificar login contra o app vivo**

Run (com o app no ar):
```bash
cd tests/visual && node run.mjs
```
Expected: imprime `OK — logado.` e uma URL que **não** contém `/site/login`.

- [ ] **Step 7: Commit**

```bash
git add tests/visual/package.json tests/visual/.gitignore tests/visual/lib/driver.mjs tests/visual/run.mjs
git commit -m "test(visual): scaffold do harness Playwright + login"
```

---

## Task 2: Modelo de resultado e gerador de relatório

**Files:**
- Create: `tests/visual/lib/report.mjs`

- [ ] **Step 1: Criar `tests/visual/lib/report.mjs`**

```javascript
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';

const REL_DIR = path.resolve('../../docs/superpowers/relatorios');
const DATE = '2026-07-15';
const SHOT_DIR = path.join(REL_DIR, 'assets', DATE);
const REPORT = path.join(REL_DIR, `${DATE}-teste-visual-telas-principais.md`);

export async function ensureDirs() {
  await mkdir(SHOT_DIR, { recursive: true });
}

/** Coletor de resultados. Cada linha: {id, tela, tipo, entrada, esperado, obtido, ok, shot?}. */
export function collector() {
  const rows = [];
  return {
    rows,
    push(row) { rows.push(row); return row; },
    /** Caminho onde uma falha deve gravar o screenshot. */
    shotPath(id) { return path.join(SHOT_DIR, `${id}.png`); },
    shotRel(id) { return `assets/${DATE}/${id}.png`; },
  };
}

const mark = (ok) => (ok ? '✅' : '❌');
const esc = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');

function tabela(rows) {
  const head = '| # | Cenário | Entrada | Esperado | Obtido | Status |\n|---|---|---|---|---|:---:|';
  const linhas = rows.map((r) =>
    `| ${r.id} | ${esc(r.cenario)} | ${esc(r.entrada)} | ${esc(r.esperado)} | ${esc(r.obtido)} | ${mark(r.ok)} |`);
  return [head, ...linhas].join('\n');
}

export async function writeReport(rows, meta) {
  await ensureDirs();
  const total = rows.length;
  const ok = rows.filter((r) => r.ok).length;
  const fail = total - ok;
  const telas = [...new Set(rows.map((r) => r.tela))];

  let md = `# Relatório — Teste Visual das Telas Principais\n\n`;
  md += `- **Data:** ${DATE}\n- **Ambiente:** ${meta.base}\n- **Commit:** ${meta.commit}\n`;
  md += `- **Resultado:** ${ok}/${total} ✅ · ${fail} ❌\n\n`;

  for (const tela of telas) {
    const sub = rows.filter((r) => r.tela === tela);
    md += `## ${tela}\n\n${tabela(sub)}\n\n`;
  }

  const falhas = rows.filter((r) => !r.ok);
  md += `## Problemas a resolver\n\n`;
  if (!falhas.length) {
    md += `Nenhum — todos os cenários passaram. 🎉\n`;
  } else {
    for (const f of falhas) {
      md += `### ${f.id} — ${f.cenario}\n`;
      md += `- **Esperado:** ${f.esperado}\n- **Obtido:** ${f.obtido}\n`;
      if (f.shot) md += `- **Screenshot:** ![${f.id}](${f.shot})\n`;
      md += `\n`;
    }
  }
  await writeFile(REPORT, md, 'utf8');
  return REPORT;
}
```

- [ ] **Step 2: Verificar geração com dados de teste**

Run:
```bash
cd tests/visual && node -e "import('./lib/report.mjs').then(async m => { const r = await m.writeReport([{id:'X1',tela:'Demo',tipo:'HP',cenario:'exemplo',entrada:'a',esperado:'b',obtido:'b',ok:true}], {base:'http://localhost:8082', commit:'test'}); console.log('escrito:', r); })"
```
Expected: imprime o caminho do relatório; o arquivo `docs/superpowers/relatorios/2026-07-15-teste-visual-telas-principais.md` existe com a tabela e "Nenhum — todos os cenários passaram".

- [ ] **Step 3: Remover o relatório de teste (será regenerado na Task 7)**

Run:
```bash
rm -f docs/superpowers/relatorios/2026-07-15-teste-visual-telas-principais.md
```
Expected: arquivo removido (não commitar o placeholder).

- [ ] **Step 4: Commit**

```bash
git add tests/visual/lib/report.mjs
git commit -m "test(visual): coletor de resultados e gerador de relatório markdown"
```

---

## Task 3: Fixtures (obra + líder + trabalhador) e asset de upload

**Files:**
- Create: `tests/visual/lib/fixtures.mjs`
- Create (gerado): `tests/visual/assets/sample.png`

- [ ] **Step 1: Gerar o PNG de upload (1×1)**

Run:
```bash
cd tests/visual && mkdir -p assets && node -e "import('node:fs').then(fs => fs.writeFileSync('assets/sample.png', Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMCAoyq0Q0AAAAASUVORK5CYII=', 'base64')))"
```
Expected: cria `tests/visual/assets/sample.png` (~70 bytes).

- [ ] **Step 2: Criar `tests/visual/lib/fixtures.mjs`**

Cria um líder e um trabalhador via a tela de Profissional (que dominamos), e **detecta** uma obra existente no dropdown do efetivo (não criamos obra — a UI de obra está fora do escopo das 3 telas). CPFs/logins recebem sufixo único por execução.

```javascript
import { goto, BASE, submitAndWait } from './driver.mjs';

const stamp = () => String(Date.now()).slice(-6);

/** Preenche e salva um Profissional pela tela /profissional/create.
 *  Marca o perfil ANTES de preencher campos que o JS mostra/esconde por perfil. */
async function criarProfissional(page, { nome, perfil, funcaoLabel, extra = {} }) {
  await goto(page, '/profissional/create');
  await page.fill('#profissional-nome', nome);
  await page.fill('#profissional-cpf', extra.cpf);
  await page.fill('#profissional-data_nascimento', '1990-01-01');
  await page.fill('#profissional-endereco', 'QA - Endereço de teste');
  await page.fill('#profissional-whatsapp', extra.whatsapp || '(11) 90000-0000');
  // Função por rótulo quando informado (o trabalhador precisa de uma função tipo DIÁRIA para
  // o efetivo virar diárias no card; "Administrativo" é tipo quinzena e zera as diárias).
  if (funcaoLabel) await page.selectOption('#profissional-funcao_id', { label: funcaoLabel });
  else await page.selectOption('#profissional-funcao_id', { index: 1 });
  // marca o perfil → o profissional-form.js revela os campos dependentes
  await page.check(`input[name="Profissional[perfisArray][]"][value="${perfil}"]`);
  if (perfil === 'profissional') {
    await page.fill('#profissional-valor_diaria', '20000'); // mask → R$ 200,00
  }
  if (perfil === 'lider') {
    await page.fill('#profissional-valor_bonus_mensal', '50000'); // R$ 500,00
    await page.fill('#profissional-login', extra.login);
    await page.fill('#profissional-senha', 'senha123');
  }
  await submitAndWait(page, '#profissional-form button[type="submit"]');
  const body = (await page.textContent('body')) || '';
  if (body.includes('não pode ficar em branco') || page.url().includes('/create')) {
    throw new Error(`Fixture: falha ao criar profissional "${nome}". Página ainda em /create.`);
  }
  return nome;
}

/** Detecta a primeira obra real no dropdown de /efetivo/registrar. */
async function detectarObra(page) {
  await goto(page, '/efetivo/registrar');
  const opts = await page.$$eval('select[name="obra_id"] option', (els) =>
    els.map((e) => ({ value: e.value, label: e.textContent.trim() })).filter((o) => o.value));
  if (!opts.length) {
    throw new Error('Fixture: nenhuma obra disponível em /efetivo/registrar. Crie uma obra antes de rodar.');
  }
  return opts[0]; // {value, label}
}

/** Garante todas as fixtures e devolve handles usados pelos cenários. */
export async function ensureFixtures(page) {
  const s = stamp();
  const lider = await criarProfissional(page, {
    nome: `QA Líder ${s}`, perfil: 'lider',
    extra: { cpf: `111.111.${s.slice(0, 3)}-${s.slice(3, 5)}`, login: `qa_lider_${s}`, whatsapp: '(11) 91111-1111' },
  });
  const trabalhador = await criarProfissional(page, {
    nome: `QA Trab ${s}`, perfil: 'profissional', funcaoLabel: 'Pedreiro',
    extra: { cpf: `222.222.${s.slice(0, 3)}-${s.slice(3, 5)}`, whatsapp: '(11) 92222-2222' },
  });
  const obra = await detectarObra(page);
  return { obra, lider, trabalhador, stamp: s };
}
```

- [ ] **Step 3: Verificar as fixtures contra o app vivo**

Run:
```bash
cd tests/visual && node -e "import('./lib/driver.mjs').then(async d => { const {browser,page}=await d.launch(); await d.login(page); const fx=await import('./lib/fixtures.mjs'); const r=await fx.ensureFixtures(page); console.log(JSON.stringify(r)); await browser.close(); })"
```
Expected: imprime um JSON com `obra`, `lider` (`QA Líder ...`), `trabalhador` (`QA Trab ...`). Sem exceção. Confirme na UI (`/profissional/index`) que os dois QA aparecem.

- [ ] **Step 4: Commit**

```bash
git add tests/visual/lib/fixtures.mjs tests/visual/assets/sample.png
git commit -m "test(visual): fixtures de obra/líder/trabalhador e asset de upload"
```

---

## Task 4: Cenários de Profissional (P1–P16)

**Files:**
- Create: `tests/visual/scenarios/profissional.mjs`

Contexto de selectors (tela `/profissional/create`, form `#profissional-form`, ids `#profissional-<attr>`, perfis por `input[name="Profissional[perfisArray][]"][value="..."]`). Mensagens de erro são asseridas por **inclusão de texto** no corpo (tolerante à classe CSS que o bootstrap5 usa). O JS `profissional-form.js` só mostra login/senha/diária/bônus quando o perfil correspondente está marcado — por isso todo cenário marca o perfil antes.

- [ ] **Step 1: Criar `tests/visual/scenarios/profissional.mjs`**

```javascript
import { goto, bodyText, submitAndWait } from '../lib/driver.mjs';

/** Preenche o form de profissional. `campos` sobrescreve/omite defaults.
 *  `perfis` é a lista de perfis a marcar (revela campos dependentes). */
async function preencher(page, { campos = {}, perfis = [] }) {
  await goto(page, '/profissional/create');
  const set = async (id, val) => { if (val !== undefined) await page.fill(id, val); };
  await set('#profissional-nome', campos.nome);
  await set('#profissional-cpf', campos.cpf);
  await set('#profissional-data_nascimento', campos.nascimento);
  await set('#profissional-endereco', campos.endereco);
  await set('#profissional-whatsapp', campos.whatsapp);
  await set('#profissional-email', campos.email);
  if (campos.funcaoIndex !== undefined) await page.selectOption('#profissional-funcao_id', { index: campos.funcaoIndex });
  for (const p of perfis) await page.check(`input[name="Profissional[perfisArray][]"][value="${p}"]`);
  await set('#profissional-valor_diaria', campos.diaria);
  await set('#profissional-valor_bonus_mensal', campos.bonus);
  await set('#profissional-login', campos.login);
  await set('#profissional-senha', campos.senha);
  if (campos.status !== undefined) await page.selectOption('#profissional-status', campos.status);
}

async function submeter(page) {
  await submitAndWait(page, '#profissional-form button[type="submit"]');
}

// CPF único e VÁLIDO (11 dígitos → ###.###.###-##) derivado do stamp + índice do cenário.
const cpfDe = (s, n) => {
  const d = ('3' + s + String(n).padStart(4, '0')).slice(0, 11).padEnd(11, '0');
  return `${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6, 9)}-${d.slice(9, 11)}`;
};

export async function run(ctx) {
  const { page, col, fx } = ctx;
  const tela = 'Profissional';
  const s = fx.stamp;
  // defaults válidos; `n` = índice do cenário (mantém CPF/nome únicos e dentro do max 14).
  const okBase = (n) => ({
    nome: `QA P ${s}-${n}`, cpf: cpfDe(s, n), nascimento: '1990-01-01',
    endereco: 'QA End', whatsapp: '(11) 93333-3333', funcaoIndex: 1, status: 'ativo',
  });
  // CPF e login que a fixture ACABOU de criar neste run — usados nos cenários de duplicidade.
  const liderCpf = `111.111.${s.slice(0, 3)}-${s.slice(3, 5)}`;
  const liderLogin = `qa_lider_${s}`;
  const rec = async (def, fn) => {
    const row = { tela, tipo: def.tipo, id: def.id, cenario: def.cenario, entrada: def.entrada, esperado: def.esperado, obtido: '', ok: false };
    try { await fn(row); } catch (e) { row.obtido = row.obtido || `ERRO: ${e.message}`; row.ok = false; }
    if (!row.ok) { row.shot = col.shotRel(def.id); await page.screenshot({ path: col.shotPath(def.id), fullPage: true }).catch(() => {}); }
    col.push(row);
  };
  const esperaTexto = async (row, txt) => {
    const body = await bodyText(page);
    const ok = body.includes(txt);
    row.ok = ok; row.obtido = ok ? `texto "${txt}" presente` : `texto "${txt}" ausente`;
  };

  // P1 — profissional puro válido
  await rec({ id: 'P1', tipo: 'HP', cenario: 'Profissional puro válido', entrada: 'campos válidos + perfil profissional + diária', esperado: 'salva e redireciona à lista' }, async (row) => {
    await preencher(page, { campos: { ...okBase(1), diaria: '20000' }, perfis: ['profissional'] });
    await submeter(page);
    const ok = !page.url().includes('/create') && !page.url().includes('/update');
    row.ok = ok; row.obtido = ok ? `redirecionou p/ ${new URL(page.url()).pathname}` : 'permaneceu no form';
  });

  // P2 — líder válido
  await rec({ id: 'P2', tipo: 'HP', cenario: 'Líder válido', entrada: 'perfil líder + login + senha + bônus mensal', esperado: 'salva' }, async (row) => {
    await preencher(page, { campos: { ...okBase(2), bonus: '50000', login: `qa_p2_${s}`, senha: 'senha123' }, perfis: ['lider'] });
    await submeter(page);
    const ok = !page.url().includes('/create');
    row.ok = ok; row.obtido = ok ? 'redirecionou' : 'permaneceu no form';
  });

  // P3 — administrativo válido (sem diária)
  await rec({ id: 'P3', tipo: 'HP', cenario: 'Administrativo válido', entrada: 'perfil administrativo + login + senha', esperado: 'salva' }, async (row) => {
    await preencher(page, { campos: { ...okBase(3), login: `qa_p3_${s}`, senha: 'senha123' }, perfis: ['administrativo'] });
    await submeter(page);
    const ok = !page.url().includes('/create');
    row.ok = ok; row.obtido = ok ? 'redirecionou' : 'permaneceu no form';
  });

  // P4 — obrigatórios vazios
  await rec({ id: 'P4', tipo: 'INV', cenario: 'Todos obrigatórios vazios', entrada: 'form vazio + perfil profissional', esperado: '"não pode ficar em branco"' }, async (row) => {
    await preencher(page, { campos: {}, perfis: ['profissional'] });
    await submeter(page);
    await esperaTexto(row, 'não pode ficar em branco');
  });

  // P5 — sem perfil (ACHADO: erro no attr `perfis` não é exibido; o form só tem `perfisArray`)
  await rec({ id: 'P5', tipo: 'INV', cenario: 'Nenhum perfil marcado', entrada: 'campos válidos, zero perfis', esperado: 'mensagem "Selecione ao menos um perfil." visível' }, async (row) => {
    await preencher(page, { campos: okBase(5), perfis: [] });
    await submeter(page);
    const temMsg = (await bodyText(page)).includes('Selecione ao menos um perfil.');
    row.ok = temMsg;
    row.obtido = temMsg ? 'mensagem exibida'
      : (page.url().includes('/create')
        ? 'BUG: não salvou, porém NENHUMA mensagem é exibida (erro no attr `perfis` não renderiza — form só tem `perfisArray`)'
        : 'salvou sem perfil');
  });

  // P6 — CPF duplicado (reusa o CPF do QA Líder criado pela fixture neste run)
  await rec({ id: 'P6', tipo: 'INV', cenario: 'CPF duplicado', entrada: `CPF ${liderCpf} (já do QA Líder)`, esperado: '"CPF já cadastrado."' }, async (row) => {
    await preencher(page, { campos: { ...okBase(6), cpf: liderCpf, diaria: '10000' }, perfis: ['profissional'] });
    await submeter(page);
    await esperaTexto(row, 'CPF já cadastrado.');
  });

  // P7 — e-mail inválido
  await rec({ id: 'P7', tipo: 'INV', cenario: 'E-mail inválido', entrada: 'email = "abc@"', esperado: 'erro de e-mail' }, async (row) => {
    await preencher(page, { campos: { ...okBase(7), email: 'abc@', diaria: '10000' }, perfis: ['profissional'] });
    await submeter(page);
    await esperaTexto(row, 'endereço de e-mail válido');
  });

  // P8 — profissional sem diária (ACHADO: máscara preenche "R$ 0,00" no load → normaliza p/ 0 → required burlado)
  await rec({ id: 'P8', tipo: 'INV', cenario: 'Profissional sem diária', entrada: 'perfil profissional, diária vazia', esperado: 'mensagem "Informe o valor da diária." (não deve salvar com 0)' }, async (row) => {
    await preencher(page, { campos: okBase(8), perfis: ['profissional'] });
    await submeter(page);
    const temMsg = (await bodyText(page)).includes('Informe o valor da diária.');
    row.ok = temMsg;
    row.obtido = temMsg ? 'mensagem exibida'
      : (page.url().includes('/index')
        ? 'BUG: salvou com diária R$ 0,00 — a máscara money preenche "R$ 0,00" no load e normaliza p/ 0, burlando o required'
        : 'não salvou, sem mensagem');
  });

  // P9 — líder sem bônus (ACHADO: mesma causa do P8 — money "R$ 0,00" no load burla o required)
  await rec({ id: 'P9', tipo: 'INV', cenario: 'Líder sem bônus', entrada: 'perfil líder + login/senha, bônus vazio', esperado: 'mensagem "Informe o bônus mensal do líder." (não deve salvar com 0)' }, async (row) => {
    await preencher(page, { campos: { ...okBase(9), login: `qa_p9_${s}`, senha: 'senha123' }, perfis: ['lider'] });
    await submeter(page);
    const temMsg = (await bodyText(page)).includes('Informe o bônus mensal do líder.');
    row.ok = temMsg;
    row.obtido = temMsg ? 'mensagem exibida'
      : (page.url().includes('/index')
        ? 'BUG: salvou líder com bônus R$ 0,00 — máscara money "R$ 0,00" no load normaliza p/ 0, burlando o required'
        : 'não salvou, sem mensagem');
  });

  // P10 — perfil com login sem login
  await rec({ id: 'P10', tipo: 'INV', cenario: 'Perfil com acesso sem login', entrada: 'perfil líder + bônus, login vazio', esperado: '"Login é obrigatório para perfis com acesso."' }, async (row) => {
    await preencher(page, { campos: { ...okBase(10), bonus: '50000', senha: 'senha123' }, perfis: ['lider'] });
    await submeter(page);
    await esperaTexto(row, 'Login é obrigatório para perfis com acesso.');
  });

  // P11 — perfil com login sem senha (novo)
  await rec({ id: 'P11', tipo: 'INV', cenario: 'Perfil com acesso sem senha', entrada: 'perfil líder + login, senha vazia', esperado: '"Senha é obrigatória para perfis com acesso."' }, async (row) => {
    await preencher(page, { campos: { ...okBase(11), bonus: '50000', login: `qa_p11_${s}` }, perfis: ['lider'] });
    await submeter(page);
    await esperaTexto(row, 'Senha é obrigatória para perfis com acesso.');
  });

  // P12 — senha curta
  await rec({ id: 'P12', tipo: 'INV', cenario: 'Senha < 6 chars', entrada: 'senha = "123"', esperado: 'erro de mínimo 6' }, async (row) => {
    await preencher(page, { campos: { ...okBase(12), bonus: '50000', login: `qa_p12_${s}`, senha: '123' }, perfis: ['lider'] });
    await submeter(page);
    await esperaTexto(row, 'deve conter pelo menos 6');
  });

  // P13 — login duplicado (reusa o login do QA Líder criado pela fixture neste run)
  await rec({ id: 'P13', tipo: 'INV', cenario: 'Login duplicado', entrada: `login = "${liderLogin}" (já do QA Líder)`, esperado: 'erro de login já em uso' }, async (row) => {
    await preencher(page, { campos: { ...okBase(13), bonus: '50000', login: liderLogin, senha: 'senha123' }, perfis: ['lider'] });
    await submeter(page);
    await esperaTexto(row, 'já foi utilizado');
  });

  // P14 — data de nascimento inválida (o <input type=date> nativo REJEITA o valor malformado)
  await rec({ id: 'P14', tipo: 'INV', cenario: 'Data de nascimento inválida', entrada: 'tentar preencher data = 2000-13-40', esperado: 'o controle de data rejeita o valor inválido' }, async (row) => {
    await goto(page, '/profissional/create');
    let rejeitou = false;
    try {
      await page.fill('#profissional-data_nascimento', '2000-13-40');
    } catch (e) {
      rejeitou = /malformed value/i.test(e.message);
    }
    const valor = await page.inputValue('#profissional-data_nascimento');
    row.ok = rejeitou || valor === ''; // o input nativo não aceita a data inválida
    row.obtido = rejeitou ? 'input type=date rejeitou o valor malformado' : `valor no campo="${valor}"`;
  });

  // P15 — máscaras (CS): CPF formata p/ ###.###.###-## e diária vira moeda R$ …,…
  await rec({ id: 'P15', tipo: 'CS', cenario: 'Máscara monetária/CPF formata em tela', entrada: 'CPF "12345678901" e diária digitada', esperado: 'CPF = 123.456.789-01 e diária no formato R$ …,…' }, async (row) => {
    await goto(page, '/profissional/create');
    await page.check('input[name="Profissional[perfisArray][]"][value="profissional"]');
    await page.type('#profissional-cpf', '12345678901');
    await page.type('#profissional-valor_diaria', '20000');
    const cpf = await page.inputValue('#profissional-cpf');
    const diaria = await page.inputValue('#profissional-valor_diaria');
    const moedaOk = /^R\$ [\d.]+,\d{2}$/.test(diaria);
    row.ok = cpf === '123.456.789-01' && moedaOk;
    row.obtido = `CPF="${cpf}", diária="${diaria}" (formato moeda ${moedaOk ? 'ok' : 'inválido'})`;
  });

  // P16 — autofill do valor por função (CS): chamada valor-funcao popula o campo
  await rec({ id: 'P16', tipo: 'CS', cenario: 'Selecionar função dispara valor-funcao', entrada: 'selecionar 1ª função', esperado: 'chamada AJAX 200 e campo diária preenchido/válido' }, async (row) => {
    await goto(page, '/profissional/create');
    await page.check('input[name="Profissional[perfisArray][]"][value="profissional"]');
    const [resp] = await Promise.all([
      page.waitForResponse((r) => r.url().includes('/profissional/valor-funcao/'), { timeout: 5000 }).catch(() => null),
      page.selectOption('#profissional-funcao_id', { index: 1 }),
    ]);
    const status = resp ? resp.status() : 0;
    const ok = status === 200;
    row.ok = ok; row.obtido = resp ? `valor-funcao respondeu ${status}` : 'chamada valor-funcao não observada';
  });
}
```

- [ ] **Step 2: Verificar os cenários de Profissional isoladamente**

Adicione um driver temporário e rode só esta tela:
```bash
cd tests/visual && node -e "
import('./lib/driver.mjs').then(async d => {
  const {browser,page}=await d.launch(); await d.login(page);
  const fx=await import('./lib/fixtures.mjs'); const f=await fx.ensureFixtures(page);
  const rep=await import('./lib/report.mjs'); const col=rep.collector(); await rep.ensureDirs();
  const p=await import('./scenarios/profissional.mjs');
  await p.run({page, col, fx:f});
  console.table(col.rows.map(r=>({id:r.id, ok:r.ok, obtido:r.obtido.slice(0,50)})));
  await browser.close();
});
"
```
Expected: tabela com P1–P16. HP (P1,P2,P3,P15,P16) com `ok:true`; INV (P4–P14) com `ok:true` (erro esperado capturado). Se algum ❌ aparecer, é um problema real do app ou um selector a ajustar — anote antes de seguir.

- [ ] **Step 3: Commit**

```bash
git add tests/visual/scenarios/profissional.mjs
git commit -m "test(visual): cenários P1–P16 da tela de Profissional"
```

---

## Task 5: Cenários de Efetivo (E1–E11)

**Files:**
- Create: `tests/visual/scenarios/efetivo.mjs`

Contexto: a tela `/efetivo/registrar?obra_id=&data=` renderiza o form `#efetivo-inserir`. O profissional é escolhido por busca AJAX (`#efetivo-busca` → clicar um `<li>` de `#efetivo-sugestoes` habilita `#efetivo-adicionar`). Como admin (não líder puro), é preciso escolher o **líder responsável** em `#efetivo-lider` antes de adicionar. Horas são `input[type=time]` (`#efetivo-entrada` e `input[name=hora_saida]`). Cenários com hora malformada (E5) ou sem entrada (E4) usam **POST direto** (a UI nativa de `time` não aceita texto livre). A flash de resultado aparece como `.alert-success`/`.alert-danger`.

- [ ] **Step 1: Criar `tests/visual/scenarios/efetivo.mjs`**

```javascript
import { goto, bodyText, postForm, submitAndWait } from '../lib/driver.mjs';

const hoje = () => new Date().toISOString().slice(0, 10);
const ontem = () => new Date(Date.now() - 864e5).toISOString().slice(0, 10);

/** Abre a tela do dia e garante um líder responsável selecionado.
 *  #efetivo-lider é Select2 (esconde o <select> nativo → selectOption dá timeout);
 *  seta a 1ª opção real via JS + change (dispara o sync do efetivo.js p/ o hidden lider_id).
 *  O 4º arg (nome do líder) é ignorado — qualquer líder disponível serve ao teste. */
async function abrirDia(page, obraValue, data) {
  await goto(page, '/efetivo/registrar', { obra_id: obraValue, data });
  await page.evaluate(() => {
    const el = document.querySelector('#efetivo-lider');
    if (!el) return; // líder puro logado: sem dropdown, já resolvido no servidor
    const opt = [...el.options].find((o) => o.value);
    if (opt) {
      el.value = opt.value;
      el.dispatchEvent(new Event('change', { bubbles: true }));
      if (window.jQuery) window.jQuery(el).trigger('change');
    }
  });
}

/** POST direto a /efetivo/adicionar com o payload COMPLETO do form. O form real sempre envia
 *  hora_saida/observacao/latitude/longitude; o controller lê $post['x'] sem coalesce (linha ~208),
 *  então omitir esses campos dá 500 (ErrorException: Undefined array key). Requer uma página
 *  /efetivo/registrar carregada (para o CSRF e o #efetivo-lider). */
async function postAdd(page, obra, data, extra) {
  return postForm(page, '/efetivo/adicionar', {
    obra_id: obra, data, lider_id: await liderId(page),
    hora_saida: '', observacao: '', latitude: '', longitude: '', ...extra,
  });
}

/** Seleciona o profissional pela busca AJAX e preenche horas; retorna sem submeter. */
async function escolherProfissional(page, nome) {
  await page.fill('#efetivo-busca', nome.slice(0, Math.max(2, nome.length)));
  await page.waitForSelector('#efetivo-sugestoes li', { timeout: 5000 });
  await page.click('#efetivo-sugestoes li:first-child');
  await page.waitForSelector('#efetivo-adicionar:not([disabled])', { timeout: 5000 });
}

async function adicionar(page, { entrada, saida }) {
  if (entrada !== undefined) await page.fill('#efetivo-entrada', entrada);
  if (saida !== undefined) await page.fill('input[name="hora_saida"]', saida);
  // o efetivo.js pede GPS (concedido no context) e só então submete → submitAndWait aguarda a navegação
  await submitAndWait(page, '#efetivo-adicionar');
}

export async function run(ctx) {
  const { page, col, fx } = ctx;
  const tela = 'Efetivo';
  const obra = fx.obra.value;
  const rec = async (def, fn) => {
    const row = { tela, tipo: def.tipo, id: def.id, cenario: def.cenario, entrada: def.entrada, esperado: def.esperado, obtido: '', ok: false };
    try { await fn(row); } catch (e) { row.obtido = row.obtido || `ERRO: ${e.message}`; row.ok = false; }
    if (!row.ok) { row.shot = col.shotRel(def.id); await page.screenshot({ path: col.shotPath(def.id), fullPage: true }).catch(() => {}); }
    col.push(row);
  };
  const flashOk = async (row, sucesso, textoEsperado) => {
    const body = await bodyText(page);
    const temSucesso = body.includes('Profissional lançado.');
    const temTexto = textoEsperado ? body.includes(textoEsperado) : true;
    row.ok = sucesso ? temSucesso : temTexto;
    row.obtido = body.match(/(Profissional lançado\.|Há um lançamento em aberto[^.]*\.|A nova entrada deve ser[^.]*\.|Informe a hora de saída[^.]*\.|Use o formato HH:MM\.|não pode ficar em branco)/)?.[0] || '(sem flash reconhecida)';
  };

  // E1 — lançamento válido hoje, só entrada
  await rec({ id: 'E1', tipo: 'HP', cenario: 'Lançamento válido hoje (só entrada)', entrada: `${fx.trabalhador}, entrada 08:00`, esperado: '"Profissional lançado."' }, async (row) => {
    await abrirDia(page, obra, hoje(), fx.lider);
    await escolherProfissional(page, fx.trabalhador);
    await adicionar(page, { entrada: '08:00' });
    await flashOk(row, true);
  });

  // E2 — entrada + saída
  await rec({ id: 'E2', tipo: 'HP', cenario: 'Entrada + saída válidas', entrada: 'entrada 08:00, saída 17:00 (dia distinto do E1 evita dobra)', esperado: '"Profissional lançado."' }, async (row) => {
    // usa a data de anteontem para não colidir com o turno aberto do E1
    const anteontem = new Date(Date.now() - 2 * 864e5).toISOString().slice(0, 10);
    await abrirDia(page, obra, anteontem, fx.lider);
    await escolherProfissional(page, fx.trabalhador);
    await adicionar(page, { entrada: '08:00', saida: '17:00' });
    await flashOk(row, true);
  });

  // E3 — turno noturno (saída < entrada)
  await rec({ id: 'E3', tipo: 'HP', cenario: 'Turno noturno (saída < entrada)', entrada: 'entrada 22:00, saída 06:00', esperado: 'aceito' }, async (row) => {
    const d = new Date(Date.now() - 3 * 864e5).toISOString().slice(0, 10);
    await abrirDia(page, obra, d, fx.lider);
    await escolherProfissional(page, fx.trabalhador);
    await adicionar(page, { entrada: '22:00', saida: '06:00' });
    await flashOk(row, true);
  });

  // E4 — sem hora de entrada (POST direto: time nativo não fica vazio via UI de forma confiável)
  await rec({ id: 'E4', tipo: 'INV', cenario: 'Sem hora de entrada', entrada: 'hora_entrada vazia', esperado: '"não pode ficar em branco"' }, async (row) => {
    await goto(page, '/efetivo/registrar', { obra_id: obra, data: hoje() });
    const html = await postAdd(page, obra, hoje(), { profissional_id: await profId(page, fx.trabalhador), hora_entrada: '' });
    const ok = html.includes('não pode ficar em branco') || html.includes('Hora de Entrada');
    row.ok = ok; row.obtido = ok ? 'erro de obrigatório capturado' : '(sem erro esperado)';
  });

  // E5 — hora em formato errado (POST direto)
  await rec({ id: 'E5', tipo: 'INV', cenario: 'Hora em formato errado', entrada: 'hora_entrada = "8h"', esperado: '"Use o formato HH:MM."' }, async (row) => {
    await goto(page, '/efetivo/registrar', { obra_id: obra, data: hoje() });
    const html = await postAdd(page, obra, hoje(), { profissional_id: await profId(page, fx.trabalhador), hora_entrada: '8h' });
    const ok = html.includes('Use o formato HH:MM.');
    row.ok = ok; row.obtido = ok ? 'mensagem de formato capturada' : '(sem mensagem de formato)';
  });

  // E6 — retroativo sem saída
  await rec({ id: 'E6', tipo: 'INV', cenario: 'Retroativo sem saída', entrada: `${ontem()} entrada 08:00, sem saída`, esperado: '"Informe a hora de saída para lançamentos de dias anteriores."' }, async (row) => {
    await abrirDia(page, obra, ontem(), fx.lider);
    await escolherProfissional(page, fx.trabalhador);
    await adicionar(page, { entrada: '08:00' });
    const body = await bodyText(page);
    const ok = body.includes('Informe a hora de saída para lançamentos de dias anteriores.');
    row.ok = ok; row.obtido = ok ? 'mensagem retroativa capturada' : '(sem mensagem retroativa)';
  });

  // E7 — retroativo com saída
  await rec({ id: 'E7', tipo: 'HP', cenario: 'Retroativo com saída', entrada: `${ontem()} entrada 08:00, saída 17:00`, esperado: 'aceito' }, async (row) => {
    await abrirDia(page, obra, ontem(), fx.lider);
    await escolherProfissional(page, fx.trabalhador);
    await adicionar(page, { entrada: '08:00', saida: '17:00' });
    await flashOk(row, true);
  });

  // E8 — dobra com 1ª entrada aberta (o E1 deixou um turno aberto hoje)
  await rec({ id: 'E8', tipo: 'INV', cenario: 'Dobra com entrada anterior aberta', entrada: '2ª entrada 18:00 hoje (1ª sem saída)', esperado: '"Há um lançamento em aberto..."' }, async (row) => {
    await abrirDia(page, obra, hoje(), fx.lider);
    // o trabalhador some da busca por ter turno aberto → POST direto exercita a regra do model
    const html = await postAdd(page, obra, hoje(), { profissional_id: await profId(page, fx.trabalhador), hora_entrada: '18:00' });
    const ok = html.includes('Há um lançamento em aberto');
    row.ok = ok; row.obtido = ok ? 'bloqueio de turno aberto capturado' : '(sem bloqueio)';
  });

  // E9 — dobra antes da saída anterior (usa a data do E7, saída 17:00; nova entrada 16:00)
  await rec({ id: 'E9', tipo: 'INV', cenario: 'Dobra antes da saída anterior', entrada: `${ontem()} nova entrada 16:00 (saída anterior 17:00)`, esperado: '"A nova entrada deve ser a partir de 17:00..."' }, async (row) => {
    const html = await postAdd(page, obra, ontem(), { profissional_id: await profId(page, fx.trabalhador), hora_entrada: '16:00', hora_saida: '20:00' });
    const ok = html.includes('A nova entrada deve ser a partir de');
    row.ok = ok; row.obtido = ok ? 'trava de horário capturada' : '(sem trava de horário)';
  });

  // E10 — dobra válida (entrada >= saída anterior 17:00)
  await rec({ id: 'E10', tipo: 'HP', cenario: 'Dobra válida (entrada ≥ saída anterior)', entrada: `${ontem()} nova entrada 17:00, saída 21:00`, esperado: '"Profissional lançado."' }, async (row) => {
    const html = await postAdd(page, obra, ontem(), { profissional_id: await profId(page, fx.trabalhador), hora_entrada: '17:00', hora_saida: '21:00' });
    const ok = html.includes('Profissional lançado.');
    row.ok = ok; row.obtido = ok ? 'dobra válida aceita' : '(dobra recusada)';
  });

  // E11 — dropdown oculta quem tem turno aberto (item 3/4)
  await rec({ id: 'E11', tipo: 'CS', cenario: 'Dropdown de busca oculta turno aberto', entrada: 'buscar o trabalhador hoje (tem turno aberto do E1)', esperado: 'não aparece na busca de hoje; aparece em dia livre' }, async (row) => {
    await goto(page, '/efetivo/registrar', { obra_id: obra, data: hoje() });
    const r1 = await page.request.get(`${(await import('../lib/driver.mjs')).BASE}/efetivo/buscar-profissional?obra_id=${obra}&data=${hoje()}&q=${encodeURIComponent(fx.trabalhador)}`);
    const abertos = await r1.json();
    const escondido = !abertos.some((p) => p.nome === fx.trabalhador);
    const diaLivre = new Date(Date.now() - 10 * 864e5).toISOString().slice(0, 10);
    const r2 = await page.request.get(`${(await import('../lib/driver.mjs')).BASE}/efetivo/buscar-profissional?obra_id=${obra}&data=${diaLivre}&q=${encodeURIComponent(fx.trabalhador)}`);
    const livres = await r2.json();
    const apareceLivre = livres.some((p) => p.nome === fx.trabalhador);
    row.ok = escondido && apareceLivre;
    row.obtido = `hoje oculto=${escondido}, dia livre visível=${apareceLivre}`;
  });
}

/** Resolve o id do profissional pelo endpoint de busca (dia livre, sempre retorna). */
async function profId(page, nome) {
  const base = (await import('../lib/driver.mjs')).BASE;
  const diaLivre = new Date(Date.now() - 20 * 864e5).toISOString().slice(0, 10);
  const obra = 0; // obra_id não filtra o profissional, só a lista de turnos abertos
  const r = await page.request.get(`${base}/efetivo/buscar-profissional?obra_id=${obra}&data=${diaLivre}&q=${encodeURIComponent(nome)}`);
  const rows = await r.json();
  const p = rows.find((x) => x.nome === nome) || rows[0];
  if (!p) throw new Error(`profId: profissional "${nome}" não encontrado`);
  return p.id;
}

/** Lê o id do líder selecionado no dropdown da tela atual (fallback: value do 1º option real). */
async function liderId(page) {
  const val = await page.$eval('#efetivo-lider', (el) => el.value).catch(() => null);
  if (val) return val;
  return await page.$eval('#efetivo-lider option:nth-child(2)', (el) => el.value).catch(() => '0');
}
```

- [ ] **Step 2: Verificar os cenários de Efetivo isoladamente**

```bash
cd tests/visual && node -e "
import('./lib/driver.mjs').then(async d => {
  const {browser,page}=await d.launch(); await d.login(page);
  const fx=await import('./lib/fixtures.mjs'); const f=await fx.ensureFixtures(page);
  const rep=await import('./lib/report.mjs'); const col=rep.collector(); await rep.ensureDirs();
  const e=await import('./scenarios/efetivo.mjs'); await e.run({page, col, fx:f});
  console.table(col.rows.map(r=>({id:r.id, ok:r.ok, obtido:r.obtido.slice(0,55)})));
  await browser.close();
});
"
```
Expected: E1–E11 listados. HP (E1,E2,E3,E7,E10) e CS (E11) com `ok:true`; INV (E4,E5,E6,E8,E9) capturando a mensagem esperada. ❌ = achado real a registrar.

- [ ] **Step 3: Commit**

```bash
git add tests/visual/scenarios/efetivo.mjs
git commit -m "test(visual): cenários E1–E11 da tela de Efetivo"
```

---

## Task 6: Cenários de Card / Financeiro (C1–C11)

**Files:**
- Create: `tests/visual/scenarios/card.mjs`

Contexto: a tela `/card/fechar?profissional_id=&quinzena=` tem o form `#card-form`. Bônus/desconto: `.card-add-item[data-tipo="bonus|desconto"]` adiciona uma `.card-item-row` com `.ci-desc` e `.js-money`; o `card.js` recalcula `.card-total-v` / `.card-nota-v` ao vivo. Diária editável: `.js-diaria-qtd` → recalcula `.js-diaria-subtotal`. Status: "Fechar card" (rascunho→fechado) e modal `#modal-pagar` (fechado→pago); anexo tipado fica no modal `#up-pagto` (dropdown `select[name=tipo]`, default NF). Salvar fora do rascunho e transição inválida são testados por **POST direto** (a UI esconde esses botões).

**Alinhamento com mudanças paralelas do financeiro (commits `e757bc4`, `c02ca04`, `c4d63fd`):**
- **Bloqueio de submit meio-preenchido** (`e757bc4`): a validação roda em fase de **captura** no `document` e dá `stopPropagation`, então o `alert()` bloqueia o submit de verdade (o ActiveForm não re-submete). O campo que falta ganha a classe **`.ci-invalid`** e o foco. → C4 verifica alert **e** `.ci-invalid`.
- **Pagamento sem anexo no modal** (`c02ca04`): o `#modal-pagar` virou só confirmação (perdeu o file input). A NF é anexada **depois**, já com o card pago, pela seção "Documentos de pagamento" → botão "Anexar documentos" → modal `#up-pagto` (com o dropdown de tipo). → C7 segue exatamente esse fluxo.
- **Filtro "sem NF" restrito a fechados/pagos** (`c4d63fd`): card em **rascunho** não conta mais como "sem NF" (nem no filtro da lista nem no relatório). → C11 vira um teste da própria regra: confirma que em rascunho **não** aparece e, após "Fechar card", **aparece**.
- **Anti-duplo-submit + no-op de status** (`d4349c1`): o `card.js` barra o 2º submit de cada form (flag no 1º envio) e o `actionMudarStatus` trata reenvio do **mesmo** status como no-op silencioso. Compatível sem mudança: o C4 valida em fase de **captura** com `stopPropagation` (roda antes do listener de bolha), e o C7 navega entre cada submit (página nova reseta o flag). O C9 posta `status='banana'` (≠ status atual) → cai na recusa normal, não no no-op.

**Ordem importa** (progressão de status monotônica): C1–C6 e C4/C10 no rascunho (edição) → **C11 fecha o card** e valida o filtro → **C7** leva a pago e anexa a NF → C8/C9 (POST direto no card já pago).

Chegamos ao card do trabalhador via `/card/index` filtrando um período que contenha o efetivo criado na Task 5, e clicando na linha dele.

- [ ] **Step 1: Criar `tests/visual/scenarios/card.mjs`**

```javascript
import { goto, bodyText, postForm, captureDialogs, submitAndWait, BASE } from '../lib/driver.mjs';

const hoje = () => new Date().toISOString().slice(0, 10);
const diasAtras = (n) => new Date(Date.now() - n * 864e5).toISOString().slice(0, 10);

/** Faixa da QUINZENA ATUAL (1–15 ou 16–fim do mês corrente) — a que contém o efetivo criado
 *  pelo harness. Filtrar por ela evita que o trabalhador apareça também na quinzena anterior
 *  (linha "pendente" vazia), o que faria abrirCard abrir o card errado. */
const quinzenaRange = () => {
  const iso = hoje();
  const [y, m, day] = iso.split('-').map(Number);
  const iniDay = day <= 15 ? '01' : '16';
  return { data_inicial: `${y}-${String(m).padStart(2, '0')}-${iniDay}`, data_final: iso };
};

/** Abre o card do trabalhador via a lista, restrito à quinzena atual.
 *  A lista mobile (a.entity-card) existe no DOM mesmo quando oculta (viewport desktop) — usar o
 *  href dela com state:'attached' funciona em qualquer viewport (o C10 roda em 390px, onde a linha
 *  de tabela desktop fica escondida e um waitFor por visibilidade daria timeout). */
async function abrirCard(page, nome) {
  await goto(page, '/card/index', quinzenaRange());
  const mobileLink = page.locator(`a.entity-card:has-text("${nome}")`).first();
  await mobileLink.waitFor({ state: 'attached', timeout: 5000 });
  const href = await mobileLink.getAttribute('href');
  await page.goto(BASE + href, { waitUntil: 'domcontentloaded' });
}

const totalTela = async (page) => (await page.textContent('.card-total-v'))?.trim() || '';
const notaTela = async (page) => (await page.textContent('.card-nota-v'))?.trim() || '';

async function addItem(page, tipo, descricao, valorDigitos) {
  await page.click(`.card-add-item[data-tipo="${tipo}"]`);
  const row = page.locator(`.card-itens[data-tipo="${tipo}"] .card-item-row`).last();
  await row.locator('.ci-desc').fill(descricao);
  await row.locator('.js-money').type(valorDigitos); // dispara máscara + recompute
  return row;
}

async function salvar(page) {
  await submitAndWait(page, '#card-form button[type="submit"]');
}

export async function run(ctx) {
  const { page, col, fx } = ctx;
  const tela = 'Card';
  const nome = fx.trabalhador;
  const dlg = captureDialogs(page);
  const rec = async (def, fn) => {
    const row = { tela, tipo: def.tipo, id: def.id, cenario: def.cenario, entrada: def.entrada, esperado: def.esperado, obtido: '', ok: false };
    try { await fn(row); } catch (e) { row.obtido = row.obtido || `ERRO: ${e.message}`; row.ok = false; }
    if (!row.ok) { row.shot = col.shotRel(def.id); await page.screenshot({ path: col.shotPath(def.id), fullPage: true }).catch(() => {}); }
    col.push(row);
  };
  const cardId = async () => {
    // O action do form é /card/salvar/NN (id no PATH, não ?id=NN).
    const action = await page.getAttribute('#card-form', 'action');
    return (action || '').split('?')[0].replace(/\/+$/, '').split('/').pop();
  };
  // "aparece na lista" = há uma LINHA de resultado com o nome. Não usar bodyText.includes():
  // o dropdown de filtro "Profissional" contém todos os profissionais e daria falso positivo.
  const naLista = async () => (await page.locator(
    `.card-list a.entity-card:has-text("${nome}"), .only-desktop tbody tr:has-text("${nome}")`
  ).count()) > 0;

  // C1 — abrir card renderiza totais
  await rec({ id: 'C1', tipo: 'HP', cenario: 'Abrir card renderiza totais', entrada: `card de ${nome}`, esperado: 'página com "Valor a receber" e base de diárias' }, async (row) => {
    await abrirCard(page, nome);
    const body = await bodyText(page);
    const ok = body.includes('Valor a receber') && body.includes('Base de diárias');
    row.ok = ok; row.obtido = ok ? `total em tela: ${await totalTela(page)}` : 'estrutura de totais ausente';
  });

  // C2 — adicionar bônus recalcula e persiste
  await rec({ id: 'C2', tipo: 'HP+CS', cenario: 'Adicionar bônus recalcula e salva', entrada: 'bônus "QA Bônus" R$ 100,00', esperado: 'total recalcula ao vivo e "Card salvo."' }, async (row) => {
    await abrirCard(page, nome);
    const antes = await totalTela(page);
    await addItem(page, 'bonus', 'QA Bônus', '10000');
    const depois = await totalTela(page);
    await salvar(page);
    const body = await bodyText(page);
    const ok = antes !== depois && body.includes('Card salvo.');
    row.ok = ok; row.obtido = `antes=${antes} depois=${depois}; flash=${body.includes('Card salvo.')}`;
  });

  // C3 — desconto com "abate nota" reduz o valor da nota
  await rec({ id: 'C3', tipo: 'HP+CS', cenario: 'Desconto abate nota recalcula', entrada: 'desconto "QA Desc" R$ 50,00 + abate nota', esperado: 'valor da nota diminui e "Card salvo."' }, async (row) => {
    await abrirCard(page, nome);
    const notaAntes = await notaTela(page);
    const linha = await addItem(page, 'desconto', 'QA Desc', '5000');
    await linha.locator('.js-considera-nota').check();
    await page.dispatchEvent('#card-form', 'change'); // força recompute
    const notaDepois = await notaTela(page);
    await salvar(page);
    const body = await bodyText(page);
    const ok = notaAntes !== notaDepois && body.includes('Card salvo.');
    row.ok = ok; row.obtido = `nota antes=${notaAntes} depois=${notaDepois}; flash=${body.includes('Card salvo.')}`;
  });

  // C4 — bônus meio-preenchido bloqueia submit com alert + destaca campo (item 6, fix e757bc4)
  await rec({ id: 'C4', tipo: 'CS', cenario: 'Bônus meio-preenchido bloqueia submit', entrada: 'novo bônus com só valor, sem descrição', esperado: 'alert "Preencha descrição e valor...", submit bloqueado e campo com .ci-invalid' }, async (row) => {
    await abrirCard(page, nome);
    await page.click('.card-add-item[data-tipo="bonus"]');
    const linha = page.locator('.card-itens[data-tipo="bonus"] .card-item-row').last();
    await linha.locator('.js-money').type('7000'); // só valor
    dlg.clear();
    await page.click('#card-form button[type="submit"]');
    await page.waitForTimeout(300); // deixa o alert e o stopPropagation agirem
    const alertou = dlg.last().includes('Preencha descrição e valor');
    const aindaNaTela = page.url().includes('/card/fechar');
    const destacou = await linha.locator('.ci-desc.ci-invalid').count(); // a descrição é o campo faltante
    row.ok = alertou && aindaNaTela && destacou > 0;
    row.obtido = `alert="${dlg.last()}"; no form=${aindaNaTela}; ci-invalid=${destacou}`;
  });

  // C5 — adicionar 2 bônus + 1 desconto (soma ao vivo); limpar 1 e salvar remove
  await rec({ id: 'C5', tipo: 'CS', cenario: 'Múltiplos itens somam; limpar remove', entrada: '+2 bônus (30/20) +1 desconto (10); depois limpa 1 bônus', esperado: 'total confere e item some ao salvar' }, async (row) => {
    await abrirCard(page, nome);
    await addItem(page, 'bonus', 'QA B1', '3000');
    await addItem(page, 'bonus', 'QA B2', '2000');
    await addItem(page, 'desconto', 'QA D1', '1000');
    const totalComTres = await totalTela(page);
    // limpa a última linha de bônus (desc + valor) → servidor descarta
    const ult = page.locator('.card-itens[data-tipo="bonus"] .card-item-row').last();
    await ult.locator('.ci-desc').fill('');
    await ult.locator('.js-money').fill('');
    await page.dispatchEvent('#card-form', 'input');
    await salvar(page);
    const body = await bodyText(page);
    const temB1 = body.includes('QA B1');
    const semB2 = !body.includes('QA B2');
    row.ok = body.includes('Card salvo.') && temB1 && semB2;
    row.obtido = `total(3 itens)=${totalComTres}; B1 mantido=${temB1}; B2 removido=${semB2}`;
  });

  // C6 — editar qtd de diária recalcula ao vivo
  await rec({ id: 'C6', tipo: 'CS', cenario: 'Editar quantidade de diária recalcula', entrada: 'alterar .js-diaria-qtd', esperado: 'subtotal e total recalculam ao vivo' }, async (row) => {
    await abrirCard(page, nome);
    const campo = page.locator('.js-diaria-qtd').first();
    if (!(await campo.count())) { row.ok = false; row.obtido = 'sem linha de diária editável (trabalhador sem efetivo por obra?)'; return; }
    const totalAntes = await totalTela(page);
    await campo.fill('3');
    await page.dispatchEvent('#card-form', 'input');
    const totalDepois = await totalTela(page);
    row.ok = totalAntes !== totalDepois;
    row.obtido = `total antes=${totalAntes} depois=${totalDepois}`;
  });

  // C11 (fecha o card e valida o filtro) — fix c4d63fd: "sem NF" só conta fechados/pagos
  await rec({ id: 'C11', tipo: 'HP', cenario: 'Filtro "somente sem NF" respeita status', entrada: 'rascunho vs. fechado, sem_nf=1', esperado: 'rascunho NÃO aparece; após "Fechar card" aparece' }, async (row) => {
    // 1) em rascunho NÃO deve aparecer no filtro sem NF
    await goto(page, '/card/index', { ...quinzenaRange(), sem_nf: 1 });
    const apareceRascunho = await naLista();
    // 2) fecha o card (rascunho → fechado)
    await abrirCard(page, nome);
    const fecharBtn = page.locator('button:has-text("Fechar card")');
    if (await fecharBtn.count()) {
      await submitAndWait(page, 'button:has-text("Fechar card")');
    }
    // 3) fechado, nota>0, sem NF → deve aparecer
    await goto(page, '/card/index', { ...quinzenaRange(), sem_nf: 1 });
    const apareceFechado = await naLista();
    row.ok = !apareceRascunho && apareceFechado;
    row.obtido = `rascunho na lista=${apareceRascunho} (esperado false); fechado na lista=${apareceFechado} (esperado true)`;
  });

  // C10 — clip do desconto salvo abre modal em largura mobile (item 5)
  await rec({ id: 'C10', tipo: 'CS', cenario: 'Anexo do desconto abre modal (mobile)', entrada: 'viewport 390px, clicar clip do desconto salvo', esperado: 'modal "Anexar comprovante" visível' }, async (row) => {
    await page.setViewportSize({ width: 390, height: 800 });
    await abrirCard(page, nome);
    const clip = page.locator('.card-itens[data-tipo="desconto"] .card-item-row .ci-docs button[data-bs-target^="#up-doc-"]').first();
    if (!(await clip.count())) { row.ok = false; row.obtido = 'sem desconto salvo com clip (C3 não persistiu?)'; await page.setViewportSize({ width: 1280, height: 900 }); return; }
    const target = await clip.getAttribute('data-bs-target');
    await clip.click();
    await page.waitForTimeout(500); // animação do modal
    const visivel = await page.locator(`${target}`).isVisible().catch(() => false);
    row.ok = visivel; row.obtido = `modal ${target} visível=${visivel}`;
    await page.setViewportSize({ width: 1280, height: 900 });
  });

  // C7 — (card já fechado pelo C11) → pago → anexar NF tipada (fix c02ca04)
  await rec({ id: 'C7', tipo: 'HP+CS', cenario: 'Pagar e anexar NF', entrada: 'fechado→pago + doc tipo NF na seção de pagamento', esperado: 'badge "NF" ao lado do documento' }, async (row) => {
    await abrirCard(page, nome);
    // garante fechado (se ainda rascunho, fecha; o C11 normalmente já fechou)
    const fecharBtn = page.locator('button:has-text("Fechar card")');
    if (await fecharBtn.count()) {
      await submitAndWait(page, 'button:has-text("Fechar card")');
    }
    // fechado → pago (modal #modal-pagar é só confirmação; não anexa arquivo aqui)
    await page.click('button:has-text("Marcar como pago")'); // abre modal, sem navegação
    await page.waitForSelector('#modal-pagar.show', { timeout: 4000 });
    await submitAndWait(page, '#modal-pagar button:has-text("Confirmar pagamento")');
    // pago → anexar documento tipado NF (seção "Documentos de pagamento" → modal #up-pagto)
    await page.click('button:has-text("Anexar documentos")'); // abre modal, sem navegação
    await page.waitForSelector('#up-pagto.show', { timeout: 4000 });
    await page.selectOption('#up-pagto select[name="tipo"]', 'nf');
    await page.setInputFiles('#up-pagto input[type="file"]', 'assets/sample.png');
    await submitAndWait(page, '#up-pagto button:has-text("Anexar")');
    const badge = await page.locator('.doc-tipo-badge:has-text("NF")').count();
    row.ok = badge > 0; row.obtido = `badges NF encontrados=${badge}`;
  });

  // C8 — salvar card fora do rascunho (POST direto; agora está pago)
  await rec({ id: 'C8', tipo: 'INV', cenario: 'Salvar card fora do rascunho', entrada: 'POST /card/salvar com card pago', esperado: '"Só é possível editar um card em rascunho."' }, async (row) => {
    const id = await cardId();
    const html = await postForm(page, `/card/salvar/${id}`, {});
    const ok = html.includes('Só é possível editar um card em rascunho.');
    row.ok = ok; row.obtido = ok ? 'guarda de rascunho capturada' : '(sem guarda)';
  });

  // C9 — transição de status inválida (POST direto)
  await rec({ id: 'C9', tipo: 'INV', cenario: 'Transição de status inválida', entrada: 'POST status="banana"', esperado: 'flash de erro (transição recusada)' }, async (row) => {
    const id = await cardId();
    const antes = await bodyText(page);
    const html = await postForm(page, `/card/mudar-status/${id}`, { status: 'banana' });
    // status inválido não transiciona; a página não deve indicar sucesso de mudança
    const ok = !html.includes('Status atualizado para banana');
    row.ok = ok; row.obtido = ok ? 'transição inválida não aplicada' : 'ACEITOU status inválido';
  });
}
```

- [ ] **Step 2: Verificar os cenários de Card isoladamente**

Rode Profissional→Efetivo→Card em sequência (o card depende do efetivo do trabalhador):
```bash
cd tests/visual && node -e "
import('./lib/driver.mjs').then(async d => {
  const {browser,page}=await d.launch(); await d.login(page);
  const fx=await import('./lib/fixtures.mjs'); const f=await fx.ensureFixtures(page);
  const rep=await import('./lib/report.mjs'); const col=rep.collector(); await rep.ensureDirs();
  const e=await import('./scenarios/efetivo.mjs'); await e.run({page, col, fx:f});
  const c=await import('./scenarios/card.mjs'); await c.run({page, col, fx:f});
  console.table(col.rows.filter(r=>r.tela==='Card').map(r=>({id:r.id, ok:r.ok, obtido:r.obtido.slice(0,55)})));
  await browser.close();
});
"
```
Expected: C1–C11. HP/CS verdadeiros; INV (C8,C9) capturando a guarda. Alguns podem depender de estado (ex.: C6 exige diária por obra) — se um ❌ for legítimo do app, registre-o.

- [ ] **Step 3: Commit**

```bash
git add tests/visual/scenarios/card.mjs
git commit -m "test(visual): cenários C1–C11 da tela de Card"
```

---

## Task 7: Orquestrador final + relatório

**Files:**
- Modify: `tests/visual/run.mjs`

- [ ] **Step 1: Reescrever `tests/visual/run.mjs` (orquestração completa)**

```javascript
import { execSync } from 'node:child_process';
import { launch, login, BASE } from './lib/driver.mjs';
import { ensureFixtures } from './lib/fixtures.mjs';
import { collector, ensureDirs, writeReport } from './lib/report.mjs';
import * as profissional from './scenarios/profissional.mjs';
import * as efetivo from './scenarios/efetivo.mjs';
import * as card from './scenarios/card.mjs';

// --only=profissional,efetivo,card
const onlyArg = (process.argv.find((a) => a.startsWith('--only=')) || '').split('=')[1];
const only = onlyArg ? onlyArg.split(',') : ['profissional', 'efetivo', 'card'];

const commit = (() => {
  try { return execSync('git rev-parse --short HEAD').toString().trim(); } catch { return 'desconhecido'; }
})();

const { browser, page } = await launch();
const col = collector();
try {
  console.log(`Login em ${BASE} ...`);
  await login(page);
  await ensureDirs();
  console.log('Preparando fixtures (obra/líder/trabalhador) ...');
  const fx = await ensureFixtures(page);
  const ctx = { page, col, fx };

  if (only.includes('profissional')) { console.log('→ Profissional'); await profissional.run(ctx); }
  if (only.includes('efetivo')) { console.log('→ Efetivo'); await efetivo.run(ctx); }
  if (only.includes('card')) { console.log('→ Card'); await card.run(ctx); }

  const file = await writeReport(col.rows, { base: BASE, commit });
  const ok = col.rows.filter((r) => r.ok).length;
  console.log(`\nRelatório: ${file}`);
  console.log(`Resultado: ${ok}/${col.rows.length} ✅ · ${col.rows.length - ok} ❌`);
  console.table(col.rows.map((r) => ({ id: r.id, tela: r.tela, ok: r.ok ? '✅' : '❌' })));
} catch (e) {
  console.error('Falha na orquestração:', e);
  if (col.rows.length) await writeReport(col.rows, { base: BASE, commit });
  process.exitCode = 1;
} finally {
  await browser.close();
}
```

- [ ] **Step 2: Rodar o passe completo**

Run (app no ar):
```bash
cd tests/visual && node run.mjs
```
Expected: imprime `→ Profissional / → Efetivo / → Card`, a tabela final com ~38 linhas, e o caminho do relatório. Screenshots das falhas em `docs/superpowers/relatorios/assets/2026-07-15/`.

- [ ] **Step 3: Revisar o relatório gerado**

Run:
```bash
sed -n '1,40p' docs/superpowers/relatorios/2026-07-15-teste-visual-telas-principais.md
```
Expected: cabeçalho com contagem ✅/❌, uma seção por tela, e "Problemas a resolver" listando só os ❌ (com screenshot). Leia os ❌ — cada um é um bug do app ou um ajuste de selector; anote quais são reais.

- [ ] **Step 4: Commit do harness e do relatório**

```bash
git add tests/visual/run.mjs docs/superpowers/relatorios/2026-07-15-teste-visual-telas-principais.md docs/superpowers/relatorios/assets/2026-07-15/
git commit -m "test(visual): orquestrador completo + relatório de checklist das telas principais"
```

---

## Fechamento

- [ ] **Triagem dos ❌**

Para cada linha ❌ do relatório, decidir: (a) bug real do app → abrir tarefa/corrigir; (b) selector/assunção do harness a ajustar → corrigir no cenário e rerodar `node run.mjs`. Registrar as decisões junto ao relatório.

- [ ] **Nota de dados**

O passe deixou registros `QA ...` no banco de dev (profissionais, efetivo, um card pago com NF). Sem limpeza, por decisão do design. Se quiser limpar depois, filtre por prefixo `QA ` em Profissional e remova (soft-delete) pela UI.

---

## Rastreabilidade (spec → tasks)

| Cenários do spec | Task |
|---|---|
| Infra: harness + login | 1 |
| Infra: relatório | 2, 7 |
| Infra: fixtures + upload | 3 |
| P1–P16 (Profissional) | 4 |
| E1–E11 (Efetivo) | 5 |
| C1–C11 (Card) | 6 |
| Passe completo + relatório enxuto + screenshots nas falhas | 7 |
