# Formulários Dinâmicos — Testes

> **Slug:** `infraestrutura-formulariosdinamicos` · **Versão analisada:** `lotep`
> **Raiz de referência:** `/home/one/projects/maxima/v7/lotep`
> **Baseline da plataforma:** 650 testes / 2107 asserções / 9 skipped (PHPUnit)

---

## 1. Estratégia

| Camada | Tipo | Por quê |
|---|---|---|
| Normalização/validação por tipo | Unit | funções puras, sem banco |
| Scoring e diagnóstico | Unit | regras numéricas com fronteiras críticas |
| Renderer | Unit de HTML | verifica estrutura/atributos sem navegador |
| CRUD e submissão | Unit com banco (`DatabaseTransactions`) | versionamento e persistência |
| API/rotas | Feature | payload e status HTTP |
| Slug | Feature | regra de reuso com soft delete |
| CMS legado | Feature (histórico) | cobre o módulo descontinuado |

O motor tem **9 arquivos de teste** dedicados (7 unitários + 2 feature) mais 1 do módulo legado.
Os unitários usam `tests/Unit/V2/Services/FormularioDinamico/FormularioTestData.php`, uma **trait**
de fixture em memória que monta `V2FormularioDinamicoVersao` com campos já na relação `campos`:

```php
protected function makeVersao(array $fields, array $scoringConfig = []): V2FormularioDinamicoVersao
{
    // cria V2FormularioDinamicoCampo em memória para cada field
    $versao->setRelation('campos', collect($campos));
    return $versao;
}
```

(`FormularioTestData.php:25-58`)

Ela oferece também `escala03()` (0=Nunca, 1=Às vezes, 2=Na maioria, 3=Quase sempre) e
`scoringConfigAutoteste()` (faixas 0/31/65/100 + disclaimer + recomendações).

## 2. Casos existentes

### 2.1 `FieldTypesTest.php` (11 casos)

| Caso | Arrange | Act | Assert |
|---|---|---|---|
| `testTextNormalizeTrimsAndNullsEmpty` | `TextFieldType` | `normalize('  olá  ')`, `''`, `null`, `'   '` | `'olá'`, `null`, `null`, `null` |
| `testTextValidateRules` | regras `required/min/max/email` | `validate` | erros só quando viola |
| `testRadioNormalizeAndValidate` | opções 0/1 | normalize `'1'`, `''`; validate `'1'`, `null`, `'99'` | válido, null, erro, erro |
| `testSelectNormalizeAndValidate` | opções a/b | validate `'a'`, `'z'` | válido, inválido |
| `testMultiSelectNormalizeAndValidate` | opções a/b/c | normalize com vazio; `max_items` | limpa vazios; erro ao exceder |
| `testCheckboxNormalizeAndValidate` | opções x/y | `min_items` | erro quando insuficiente |
| `testRangeNormalizeAndValidateFronteiras` | min 0, max 10 | 0, 10, 11, -1 | aceita extremos, rejeita fora |
| `testSatisfactionValidate` | 1..5 | `'5'`, `null`, `'6'`, `'abc'` | só 1..5 |
| `testTextareaNormalizeAndValidate` | min 3, max 10 | 2, 10, 11 chars | respeita limites |
| `testRegistroTiposDoMotor` | container | `fieldTypes()` | **8 tipos**, sem `file` |
| `testTipoDesconhecidoLancaExcecao` | — | `fieldType('inexistente')` | `InvalidArgumentException` |

### 2.2 `ValidationServiceTest.php` (4 casos)

| Caso | O que prova |
|---|---|
| `testRespostasValidasNaoGeramErros` | schema válido → `[]` |
| `testObrigatoriosVaziosGeramErrosPorNome` | erro indexado pelo `name` do campo |
| `testRulesDoCampoNaoObrigatorioNaoForcamResposta` | campo opcional ausente não erra |
| `testStringCurtaRespeitaMin` | `rules.min` aplicado ao texto |

### 2.3 `ScoringServiceTest.php` (10 casos)

| Caso | Cenário | Resultado esperado |
|---|---|---|
| `testRadioSomaWeightDaOpcaoSelecionada` | escala 0–3, resposta `'2'` | score 2, max 3, detail 2 |
| `testTrezeItensMax39EScoreMaximo` | 13 perguntas, todas `'3'` | 39 / 39 |
| `testTudoZeroDaScoreZero` | 13 perguntas, todas `'0'` | 0 / 39 |
| `testMultiselectSomaWeightsSelecionados` | pesos 1/2/3, seleciona a+c | 4 / 6 |
| `testRangeValorDiretoEMaxDasRules` | range 0–10, valor 7, sem peso | 7 / 10 |
| `testTextRespondidoValeOWeight` | texto com `weight = 5` | 5/5 preenchido; 0/5 vazio |
| `testCamposSemRespostaContribuemZero` | resposta ausente | 0 / 3 |
| `testAlternativaSemPesoUsaValorDaPergunta` | opções sem peso, `weight = 3` | 3 / 3 se respondida |
| `testMultiselectSemPesoUsaValorDaPergunta` | checkbox sem peso, `weight = 2` | 2 / 2 |
| `testRangeComValorEProporcional` | range 0–10, valor 5, `weight = 5` | 2,5 / 5 |

### 2.4 `DiagnosisServiceTest.php` (6 casos)

| Caso | O que prova |
|---|---|
| `testFronteiraPercentualDasFaixas` | 12/39 = 30,77% → leve; 13/39 = 33,3% → moderada; 26/39 = 66,7% → alta; 39/39 → alta |
| `testResultadoCarregaTituloMensagemRecomendacoesEDisclaimer` | chaves do resultado |
| `testSemBandsRetornaVazio` | configuração sem `bands` → `[]` |
| `testFaixasAbsolutas` | NPS 0–10: 6 → detrator; 7 → neutro; 9/10 → promotor |
| `testScoreAcimaDaUltimaFaixaCaiNaUltima` | score > max cai na última faixa |
| `testMaxScoreZeroNaoDividePorZero` | 0/0 → faixa de 0% |

### 2.5 `RendererServiceTest.php` (5 casos)

| Caso | O que verifica |
|---|---|
| `testRadioUsaFieldsetLegendELabelAssociado` | `<fieldset>`, `<legend>`, `id="df-q1-3"`, `name="answers[q1]"`, `aria-required`, `aria-describedby` |
| `testErroRendeAriaInvalidEErroDescrito` | `aria-invalid="true"`, `df-nome-error`, mensagem |
| `testValorPreenchidoNaoEhEscapeadoDuplo` | `Maria & Joana` → `Maria &amp; Joana`; sem `alert` |
| `testRangePossuiOutputEVariavelNome` | `<output>`, `max="10"`, `value="7"` |
| `testMultiSelectENumeroDeCamposRenderizados` | `answers[interesses][]`, range, satisfaction, `data-df-group` |

### 2.6 `SubmissionServiceTest.php` (5 casos)

| Caso | Arrange | Assert |
|---|---|---|
| `testSubmissaoCompletaPersisteEAnonimiza` | 13 radios obrigatórios, todas `'3'`, IP `192.168.0.10` | score 39, faixa `atencao_alta`, `ip_hash` sem o IP, `consent_at`, `result_token`, `answers.q1 = '3'` |
| `testSemConsentimentoLancaApenasErroDeConsentimento` | `consent = false` | `ValidationException` só com `lgpd_consent` |
| `testRespostasInvalidasLancamComErrosDeCampos` | sem respostas | erro em `q1` |
| `testSemBandsPersisteSemLevelNemResultData` | config sem bands | `result_level` e `result_data` nulos |
| `testConsentNaoExigidoQuandoConfigurado` | `consent_required = false` | grava com `consent_at = null` |

### 2.7 `ExportServiceTest.php` (5 casos)

| Caso | O que prova |
|---|---|
| `testToCsvGeraCabecalhoELinhas` | cabeçalho contém `score`; linha contém faixa e valor |
| `testAggregateContaPorLevelESerie` | total 3, `by_level` correto, `daily` não vazio |
| `testDistributionPorcentagemDeCadaResposta` | 1 de 2 em cada opção = 50%; textarea respondida/não respondida |
| `testQuestionScoresMediaPorPerguntaComMaisDeUma` | média e máximo por pergunta; `percent` 100% |
| `testQuestionScoresRetornaVazioComUmaPergunta` | uma pergunta → `[]` |

### 2.8 `FormularioCrudServiceTest.php` (11 casos)

| Caso | O que prova |
|---|---|
| `testCreateGeraFormularioVersaoECampos` | versão 1, status ativo, `published_at`, 2 campos, `required`, opções |
| `testUpdateSemSubmissoesAlteraNoLugar` | versão continua 1; campos substituídos |
| `testUpdateComSubmissoesPromoveVersao` | current_version 2; submissão antiga continua na v1 |
| `testDestroySoftDeletaFormulario` | `withTrashed()` encontra |
| `testOptionsPorVirgulaGeramValueTextoIguaisSemPeso` | 4 opções; `weight = 0` |
| `testOptionsPorVirgulaDeduplicamESaltamVazias` | `A, , A, B` → 2 |
| `testOptionsSuportamNotaPorResposta` | `nota` vira `weight` inteiro |
| `testOptionsIgnoramTextosVaziosEDuplicadosEValidamNota` | negativa→0, não numérica→0, 150→100 |
| `testOptionsSemNotaNaoCriamPesoAutomatico` | todos `weight = 0` |
| `testScoreEnabledFicaNoMetadata` | flag preserva outras chaves de metadata |
| `testScoringConfigJsonInvalidoLanca` | `InvalidArgumentException` |

### 2.9 Feature

| Arquivo | Casos |
|---|---|
| `FormularioDinamicoApiTest.php` | schema 200 com 13 campos; slug inexistente 404; submit com consentimento 201/score 39/`atencao_alta`/`ip_hash`/`consent_at`/`origin=api`; submit sem consentimento 422; resultados com `total`/`by_level`/`daily` |
| `FormularioSlugReusoTest.php` | slug de apagado é reutilizado e antigo renomeado com `--`; faixas gravadas em ordem crescente com `faixa_1`; sem faixas → `scoring_config = null`; slug ativo bloqueado |

O teste de API depende de `AutoTesteInstrumentoService::ensure()`, que cria o instrumento real de
13 perguntas — é um teste **de integração com consumidor**, não apenas do motor.

### 2.10 Módulo legado

`tests/Feature/V2/Modules/Infraestrutura/FormulariosDinamicos/FormularioCmsControllerTest.php`
(7 casos) registra `view()->addNamespace('v2-formularios', __DIR__.'/stubs/views')` e espera:

- `index`/`create`/`edit` retornando `View` (`v2-formularios::cms.forms.*`);
- `store`/`update`/`destroy` alterando `v2_forms`;
- `create` sem permissão lançando `HttpException`.

**O teste está desatualizado:** o controller atual redireciona em `index`, `create` e `edit`; os
casos que esperam `View` falham. Ele também depende de `Sentinel` e de `v2_roles`, e o mock de
`V2AuditLogger` cobre só `activity()`.

## 3. Fixtures necessárias

| Fixture | Origem | Observação |
|---|---|---|
| Versão em memória | trait `FormularioTestData::makeVersao` | não toca banco |
| Escala 0–3 | `escala03()` | pesos 0/1/2/3 |
| Faixas percentuais | `scoringConfigAutoteste()` | 0/31/65/100 + disclaimer |
| Formulário persistido | helpers locais dos testes | `DatabaseTransactions` |
| Instrumento AutoTeste | `AutoTesteInstrumentoService::ensure()` | 13 campos reais |
| Usuário admin de testes | `Sentinel` + `V2Role` | apenas no teste legado |
| Views stub | `tests/Feature/V2/Modules/Infraestrutura/FormulariosDinamicos/stubs/views/cms/forms/` (três stubs) | apenas no teste legado |

Não há factory de model; os testes criam registros com `v2_uuid()` explícito.

## 4. Comando

```bash
# unitários + feature do motor
vendor/bin/phpunit tests/Unit/V2/Services/FormularioDinamico tests/Feature/V2/Services/FormularioDinamico

# somente o módulo legado (atualmente falha; ver 08)
vendor/bin/phpunit tests/Feature/V2/Modules/Infraestrutura/FormulariosDinamicos

# e2e (requer token de avaliação)
npx playwright test tests-e2e/tests/cms-v2-formulario-dinamico-crud.spec.js tests-e2e/tests/portal-v2-autoteste.spec.js
```

## 5. Lacunas de cobertura

| Lacuna | Risco |
|---|---|
| Nenhum teste de `consent_text` com HTML | XSS armazenado no portal não é detectado |
| Nenhum teste de CSRF/portal via router | fluxo HTTP só é coberto por e2e |
| Nenhum teste de escala (N submissões) | gargalo de memória não é medido |
| Nenhum teste de `expires_at` no portal/API | expiração pode estar quebrada |
| Nenhum teste de `result_token` de outro formulário via HTTP | coberto apenas no controller |
| Nenhum teste de IP IPv6/rotação de `APP_KEY` | mudança de hash não é observada |
| Teste legado falha por expectativa antiga | falso vermelho crônico |

## 6. Plano de testes para a reconstrução no V7

| Nível | Teste | Critério |
|---|---|---|
| Unit | `CalcularPontuacaoTest` | mesmos 10 cenários do legado + pesos fracionários |
| Unit | `ClassificarFaixaTest` | fronteiras 0/31/65/100, `floor`, absolutas, mistas, `max = 0` |
| Unit | `ValidarRespostasTest` | um caso por tipo + regras opcionais |
| Feature | `FormulariosPortalTest` | GET renderiza `fieldset/legend`, POST grava, confirmação não expõe score |
| Feature | `FormulariosApiTest` | 201/422/404; resource sem `peso`/`faixa` |
| Feature | `FormulariosCmsTest` | cada permissão (ver/editar/aprovar/arquivar) nega e permite |
| Feature | `FormularioVersionamentoTest` | editar sem submissão mantém v1; com submissão cria v2 e preserva v1 |
| Feature | `FormularioRetencaoTest` | `retention:expunge` anonimiza e mantém agregados |
| Feature | `FormularioAssinaturaTest` | link expirado/token de outro formulário → 403/404 |
| E2E | `formularios-cms-editor.spec.ts` | criar formulário, duas perguntas, faixas, publicar, responder, ver relatório |
| E2E | `formularios-portal-a11y.spec.ts` | axe sem violações; foco/erro; teclado no editor de faixas |

Regra de ouro do V7 (ADR-038/039): teste de fronteira para toda regra numérica e nenhum `skip`
sem justificativa registrada.
