Files
Exporta-SD/scripts/teste_e2e.py
T
wanderandClaude Fable 5.1 a4b1374fad Linha de OS: tabela linhas-os.yaml como fallback quando o SD.md não declara
Decisão de 2026-09-04, revendo a de 2026-09-03 (sem de-para): o .md continua
mandando, e a tabela contrato/linhas-os.yaml (OS Mãe × linha × item) só entra
quando o entregável não declara `linha_os`. Linha declarada vai como está e não
é conferida contra a tabela; existência/item/status seguem sendo do banco.

- caminhos.LINHAS_OS; regras_sd: Canonico(itens, linhas_os), carregar_linhas_os
  (arquivo ausente = fallback desligado), preparar_linhas_os (valida: formato,
  linha repetida, dois `padrao`, `padrao` não booleano) e linha_da_tabela.
- exporta_sd: ausente → padrão do item; 2+ candidatas sem padrão → pendência
  nomeando-as; item fora da tabela → pendência. CLI informa quantas linhas
  vieram da tabela.
- app: sessao.adotar_linhas_da_tabela grava a linha no frontmatter da sessão
  com comentário de origem, para o Salvar SD.md deixar a SD autodeclarada;
  a tela marca `tabela` e mostra a data da tabela.
- testes: SD18, SD27, SD31–36 passam a exportar de saída; SD10 só PO+objetivo;
  casos sintéticos para tabela ausente, ambígua, sem o item e malformada.
- deploy: linhas-os.yaml vive em clientes/ (gitignored) — vai por scp ao volume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:22:15 -03:00

397 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
teste_e2e.py — o fluxo inteiro, do upload ao download, sem servidor rodando.
Usa o test client do Flask: exercita exatamente as rotas de app/servidor.py,
sem porta aberta e sem depender de o servidor estar de pé.
O que esta suíte existe para provar, na ordem:
1. upload NUNCA baixa nada — o download só sai do /exportar, depois de um
clique, e só quando o exportador não tem pendência;
2. SD com pendência é recusada no servidor mesmo por POST direto, e a tela
mostra formulário SÓ com os campos pendentes;
3. a correção feita no formulário chega ao JSON — e o JSON é byte-idêntico
ao do exporta_sd.py na linha de comando sobre um SD.md com a mesma
correção;
4. campo não pendente não é tocado por um Salvar (o formulário só manda o
que está pendente, e o servidor só toca no que vem).
Roda assim:
python3 scripts/teste_e2e.py
0 = tudo passou; 1 = alguma checagem falhou
"""
from __future__ import annotations
import html as H
import json
import re
import subprocess
import sys
import tempfile
from io import BytesIO
from pathlib import Path
RAIZ = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RAIZ / "scripts"))
sys.path.insert(0, str(RAIZ / "app"))
from caminhos import PROJETOS # noqa: E402
from regras_sd import ler_sd_texto, secao # noqa: E402
import servidor # noqa: E402
FALHAS: list[str] = []
CLIENTE = servidor.app.test_client()
# Lida da tabela, não fixada: o cabeçalho de linhas-os.yaml manda atualizar
# `atualizado_em` a cada edição, e um teste pinado ficaria vermelho à toa.
DATA_TABELA = servidor.canonico().linhas_os["atualizado_em"]
SD8 = PROJETOS / "P2-vacinacao/sds/SD8-mvp-vacina-em-dia/SD.md"
SD10 = PROJETOS / "P2-vacinacao/sds/SD10-crie/SD.md"
SD20 = PROJETOS / "P4-cancer-de-mama/sds/SD20-siscan-d1/SD.md"
SD24 = PROJETOS / "P5-portal-rmds/sds/SD24-mvp-institucional/SD.md"
SD31 = PROJETOS / "P7-assistencia-farmaceutica/sds/SD31-fundacao-da-base-de-dispensacao/SD.md"
def ck(cond, desc):
print(f" {'ok ' if cond else 'FALHA'} {desc}")
if not cond:
FALHAS.append(desc)
def post(rota, dados=None, **kw):
return CLIENTE.post(rota, data=dados or {}, follow_redirects=True, **kw)
def texto(resp):
return resp.get_data(as_text=True)
def enviar(caminho: Path, nome: str | None = None, conteudo: bytes | None = None):
dados = {"arquivo": (BytesIO(conteudo if conteudo is not None else caminho.read_bytes()),
nome or caminho.name)}
return post("/upload", dados, content_type="multipart/form-data")
def pagina():
return texto(CLIENTE.get("/revisar", follow_redirects=True))
def reiniciar():
post("/reiniciar")
def campos_do_form(pag: str) -> dict:
"""Relê o formulário de pendências como o navegador o reenviaria."""
campos = {}
for m in re.finditer(r'<input[^>]*name="([^"]+)"[^>]*>', pag):
tag = m.group(0)
if any(t in tag for t in ('type="radio"', 'type="checkbox"', 'type="file"')):
continue
v = re.search(r'value="([^"]*)"', tag)
campos[m.group(1)] = H.unescape(v.group(1)) if v else ""
for m in re.finditer(r'<textarea[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', pag, re.S):
campos[m.group(1)] = H.unescape(m.group(2))
for m in re.finditer(r'<select[^>]*name="([^"]+)"[^>]*>(.*?)</select>', pag, re.S):
sel = re.search(r'<option value="([^"]*)"[^>]*selected', m.group(2))
campos[m.group(1)] = H.unescape(sel.group(1)) if sel else ""
return campos
def estado_na_tela(pag: str) -> str:
"""O estado que a tela DECLARA ("estado: <strong>…</strong>"). A trilha do
cabeçalho repete os nomes de todos os estados, então procurar o texto solto
daria positivo sempre."""
m = re.search(r"estado: <strong>([^<]+)</strong>", pag)
return m.group(1) if m else ""
def baixou(resp) -> bool:
return "attachment" in resp.headers.get("Content-Disposition", "")
def exportar_pela_cli(sd_md: Path) -> str:
with tempfile.TemporaryDirectory() as tmp:
r = subprocess.run([sys.executable, str(RAIZ / "scripts/exporta_sd.py"), str(sd_md),
"--dir", tmp], capture_output=True, text=True, encoding="utf-8")
saidas = list(Path(tmp).glob("*.json"))
if not saidas:
return f"(CLI não gerou JSON: {r.stderr.strip()})"
return saidas[0].read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
def sd_pronta():
print("\n1. SD31: o .md não declara linha de OS; a tabela preenche, o clique exporta — e nada sai antes dele")
reiniciar()
r = enviar(SD31)
ck(not baixou(r), "o upload NÃO devolve download — devolve a tela de revisão")
pag = texto(r)
ck(estado_na_tela(pag) == "Pronta para exportar",
"estado: pronta — as 4 linhas que o SD.md não declara vieram de contrato/linhas-os.yaml")
ck("PENDENTE" not in pag and 'id="form-pendencias"' not in pag,
"sem pendência não há formulário nenhum na tela")
ck(pag.count(">tabela</span>") >= 4 and "linhas-os.yaml" in pag and DATA_TABELA in pag,
"a tela marca a origem em cada linha e diz a data da tabela")
ck("1090-L1" in pag, "a linha padrão de I-02 aparece no resumo")
ck("Exportar JSON" in pag and "disabled" not in pag.split("Exportar JSON")[0][-200:],
"o botão Exportar está habilitado")
ck(not baixou(CLIENTE.get("/revisar")), "GET /revisar não baixa nada — só o /exportar baixa")
r = post("/exportar")
ck(baixou(r) and "P7-SD31.json" in r.headers.get("Content-Disposition", ""), "download entregue")
baixado = texto(r)
p = json.loads(baixado)
ck(p["sd"]["codigo"] == "P7·SD31", "é o JSON da SD certa")
ck(all(e["item"] == "I-02" and e["ordem_servico"]["linha"] == "1090-L1" for e in p["entregaveis"]),
"todo entregável (I-02) saiu com a linha padrão do item na tabela")
ck(p["sd"]["status"] == "Planejado" and all(e["status"] == "Rascunho" for e in p["entregaveis"]),
"status nos rótulos do contrato")
ck(estado_na_tela(pagina()) == "JSON exportado", "a tela registra a exportação")
# A mesma régua da CLI, dos dois lados: sobre o SD.md ORIGINAL (a CLI
# consulta a mesma tabela) e sobre o SD.md gravado pelo Salvar SD.md (que
# agora declara as linhas, com a origem em comentário).
ck(baixado == exportar_pela_cli(SD31), "bytes idênticos aos do exporta_sd.py sobre o SD.md original")
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
txt = alvo.read_text(encoding="utf-8") if alvo.exists() else ""
ck(txt.count("linha_os: 1090-L1") == 4 and txt.count(f"# da tabela linhas-os.yaml ({DATA_TABELA})") == 4,
"o SD.md gravado declara as quatro linhas, cada uma com a origem em comentário")
ck(baixado == exportar_pela_cli(alvo), "bytes idênticos aos do exporta_sd.py sobre o SD.md gravado")
def pendencia_de_semanas():
print("\n2. SD24: semanas declaradas ≠ datas → pendente; corrigido no formulário, exporta")
reiniciar()
pag = texto(enviar(SD24))
ck(estado_na_tela(pag) == "Com pendências", "estado: com pendências")
ck("18 dias" in pag and "3 semana" in pag and "declara 1" in pag,
"a pendência diz os dois números — derivado e declarado — e aponta o entregável")
c = campos_do_form(pag)
ck(set(c) == {"e3__semanas", "e3__inicio", "e3__fim"},
f"o formulário traz SÓ o entregável pendente: semanas + as datas dele ({sorted(c)})")
r = post("/exportar")
ck(not baixou(r) and "Exportação recusada" in texto(r),
"POST /exportar direto é recusado pelo servidor, com a razão")
c["e3__semanas"] = "3"
pag = texto(post("/revisar", c))
ck(estado_na_tela(pag) == "Pronta para exportar", "corrigidas as semanas, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
e4 = p["entregaveis"][3]
ck(e4["numero_semanas"] == 3 and e4["ust"] is None and e4["valor_entregavel"] is None,
"o JSON leva 3 semanas; UST e valor saem null para o importador derivar")
ck(p["sd"]["nome"] == "MVP Institucional e Protótipos do Portal RMDS",
"o título — que NÃO estava no formulário — continua intacto")
# Apagar a declaração também resolve: em branco, deriva.
reiniciar()
c = campos_do_form(texto(enviar(SD24)))
c["e3__semanas"] = ""
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
"semanas em branco = 'derive das datas' — também destranca")
def teto_de_28_dias():
print("\n3. SD8: janela de 31 dias e duas divergências de semanas")
reiniciar()
pag = texto(enviar(SD8))
ck("3 pendência(s)" in pag, "três pendências")
ck("31 dias" in pag and "teto de 28 dias" in pag, "o teto de 28 dias é nomeado, com os dias reais")
c = campos_do_form(pag)
ck(set(c) == {"e1__semanas", "e1__inicio", "e1__fim", "e2__semanas", "e2__inicio", "e2__fim",
"e7__inicio", "e7__fim"},
f"formulário só dos entregáveis 2, 3 e 8 — janela inteira de cada um ({sorted(c)})")
c["e1__semanas"] = "2"
c["e2__semanas"] = "1"
c["e7__fim"] = "2026-06-30" # 28 dias → 4 semanas, o que o SD já declarava
pag = texto(post("/revisar", c))
ck(estado_na_tela(pag) == "Pronta para exportar", "corrigidos os três, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
e8 = p["entregaveis"][7]
ck(e8["data_prevista_termino"] == "2026-06-30" and e8["numero_semanas"] == 4 and e8["ust"] is None,
"o entregável 8 saiu com a data corrigida e 4 semanas; a UST é do importador")
ck(p["entregaveis"][1]["numero_semanas"] == 2 and p["entregaveis"][2]["numero_semanas"] == 1,
"os entregáveis 2 e 3 saíram com as semanas das datas")
ck(p["sd"]["status"] == "Em andamento" and e8["status"] == "Documentado",
"estado `documentada` → sd.status 'Em andamento', entregável 'Documentado'")
ck(p["entregaveis"][0]["alocacoes"][0]["perfil"] == "Especialista de Inteligência (Cientista de Dados)",
"o alias do perfil saiu traduzido para o nome do cadastro")
def po_e_objetivo():
print("\n4. SD10: PO em branco e Seção 1 ausente → formulário com os dois; corrigidos, exporta")
reiniciar()
pag = texto(enviar(SD10))
c = campos_do_form(pag)
ck(set(c) == {"sd__po_responsavel", "corpo__objetivo"},
f"PO e objetivo no formulário — nada mais: as cinco linhas de OS vieram da tabela ({sorted(c)})")
ck("[a designar]" not in pag and "[a redigir" not in pag, "nenhum placeholder inventado na tela")
c["sd__po_responsavel"] = "Guilherme Bernardino"
c["corpo__objetivo"] = "Permitir à equipe do CRIE manter as regras de elegibilidade sem deploy."
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar", "preenchidos, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
ck(p["sd"]["po_responsavel"]["nome"] == "Guilherme Bernardino", "o PO chega ao JSON")
ck(p["sd"]["objetivo"].startswith("Permitir à equipe do CRIE"), "o objetivo chega ao JSON")
corpo = servidor.ATUAL["sessao"].corpo
ck(secao(corpo, "1") is not None and corpo.index("## 1.") < corpo.index("## 5."),
"a Seção 1 foi criada no corpo, antes da Seção 5")
ck([e["ordem_servico"]["linha"] for e in p["entregaveis"]]
== ["2587-L3", "2587-L3", "1090-L1", "2587-L3", "2587-L3"],
"cada entregável saiu com o padrão do SEU item na tabela (n3 é I-02; os demais, I-05)")
def sem_datas():
print("\n5. SD20: sem datas, e PO 'PENDENTE' — reprovada, nada baixa")
reiniciar()
pag = texto(enviar(SD20))
ck("11 pendência(s)" in pag, "onze pendências: PO + 5 × (início, fim)")
ck("convenção do repositório" in pag, "o PO 'PENDENTE — ...' é reconhecido como não-nome")
ck(not baixou(post("/exportar")), "POST /exportar direto não baixa nada")
def dado_invalido():
print("\n6. Dado inválido vira mensagem na tela, não traceback no console")
reiniciar()
c = campos_do_form(texto(enviar(SD24)))
c["e3__inicio"] = "31/13/2026"
pag = texto(post("/revisar", c))
ck("ilegível" in pag and "Traceback" not in pag, "data ilegível é pendência nomeada")
c = campos_do_form(pag)
c["e3__inicio"] = "2026-07-13"
c["e3__fim"] = "2026-07-10"
ck("anterior ao início" in texto(post("/revisar", c)), "fim antes do início é pendência nomeada")
def entrada_ruim():
print("\n7. Entrada ruim: uma mensagem específica para cada caso")
for nome, conteudo, esperado, desc in (
("notas.txt", b"nao sou markdown", "não é um arquivo .md", "extensão errada"),
("vazio.md", b" \n", "está vazio", "arquivo vazio"),
("semfm.md", "# só prosa\n".encode(), "primeira linha deve ser", "sem frontmatter"),
("aberto.md", "---\nsd: X\n\n# sem fecho\n".encode(), "não fechado", "frontmatter não fechado"),
("lista.md", "---\n- a\n- b\n---\n".encode(), "não é um mapeamento", "frontmatter que não é mapa"),
("latin.md", "---\nsd: Vacinação\n---\n".encode("latin-1"), "não está em UTF-8", "fora de UTF-8"),
):
reiniciar()
ck(esperado in texto(enviar(Path(nome), nome, conteudo)), desc)
reiniciar()
pag = texto(enviar(Path("minima.md"), "minima.md",
"---\nsd: X\nestado: voando\nentregaveis: []\n---\n# t\n".encode()))
ck("fora da notação" in pag and "não tem entregáveis" in pag and "voando" in pag,
"frontmatter mínimo: identificador, estado e entregáveis reprovados com nome")
ck("Este conserto é no" in pag, "pendência estrutural aponta para o SD.md, sem controle na tela")
def writeback():
print("\n8. Salvar SD.md: correção gravada, comentários preservados, prosa reanexada")
reiniciar()
c = campos_do_form(texto(enviar(SD10)))
c["sd__po_responsavel"] = "PO de teste"
c["corpo__objetivo"] = "Objetivo de teste."
post("/revisar", c)
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
ck(alvo.exists(), "arquivo gravado")
txt = alvo.read_text(encoding="utf-8")
ck("CORREÇÃO 17/08/2026" in txt and "protótipo navegável está EM CRIAÇÃO" in txt,
"o comentário datado do campo `estado` sobreviveu inteiro")
ck("_corpo" not in txt, "nenhuma chave _corpo dentro do frontmatter")
ck('po_responsavel: PO de teste' in txt or 'po_responsavel: "PO de teste"' in txt, "o PO foi gravado")
ck("## 1. Objetivo" in txt and "## 5. Enquadramento no Termo de Referência" in txt,
"a Seção 1 nova e a prosa original estão no arquivo")
d, _ = ler_sd_texto(txt)
ck(d["po_responsavel"] == "PO de teste" and len(d["entregaveis"]) == 5,
"o arquivo gravado reparseia com os mesmos entregáveis")
reiniciar()
enviar(SD10)
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo)})
ck(not alvo.exists(), "sem a caixa de confirmação marcada, nada é gravado")
def linha_de_os_no_formulario():
print("\n9. Linha de OS: declarada, só o formato é conferido; apagada, a tabela preenche o padrão do item")
sd_md = ("---\n"
'sd: "P9·SD99"\nprojeto: "P9 · Teste"\ntitulo: "Sintética"\n'
'po_responsavel: "Alguém"\nversao: "V1"\nestado: rascunho\n'
"entregaveis:\n"
' - n: 1\n nome: "Um"\n tipo: Descoberta\n item: I-02\n'
' linha_os: "L1"\n inicio: 2026-09-01\n fim: 2026-09-11\n'
"---\n# T\n\n## 1. Objetivo\n\nAlgo.\n").encode("utf-8")
reiniciar()
pag = texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md))
ck("fora do formato" in pag and "1090-L1" in pag, "linha mal formada: pendência que mostra o formato")
ck('<input name="e0__linha_os"' in pag and "<select" not in pag,
"campo de texto: a pessoa corrige o que declarou, a tabela não se mete no declarado")
ck(">tabela</span>" not in pag, "linha declarada (mesmo errada) não é trocada pela da tabela")
c = campos_do_form(pag)
c["e0__linha_os"] = "9999-L7"
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
"linha bem formada que a tabela não conhece PASSA — quem confere existência é o banco")
p = json.loads(texto(post("/exportar")))
ck(p["entregaveis"][0]["ordem_servico"]["linha"] == "9999-L7", "o que a pessoa declarou vai no JSON")
# Apagar a declaração é "não sei": a tabela entra e dá o padrão do item.
reiniciar()
c = campos_do_form(texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md)))
c["e0__linha_os"] = ""
pag = texto(post("/revisar", c))
ck(estado_na_tela(pag) == "Pronta para exportar" and ">tabela</span>" in pag,
"em branco, a tabela preenche o padrão de I-02 e a tela marca a origem")
e0 = servidor.ATUAL["sessao"].dados["entregaveis"][0]
ck(e0.get("linha_os") == "1090-L1", "a linha da tabela foi escrita no frontmatter da sessão (para o Salvar SD.md)")
p = json.loads(texto(post("/exportar")))
ck(p["entregaveis"][0]["ordem_servico"]["linha"] == "1090-L1", "e é ela que vai no JSON")
def editar_depois_de_exportar():
print("\n10. Editar depois de exportar desfaz o rótulo 'exportada'")
reiniciar()
enviar(SD31) # pronta de saída: as linhas de OS vêm da tabela
post("/exportar")
ck(estado_na_tela(pagina()) == "JSON exportado", "exportada")
post("/revisar", {"sd__titulo": "outro título"})
pag = pagina()
ck(estado_na_tela(pag) == "Pronta para exportar",
"a edição volta a sessão para 'pronta' — o que foi exportado era outra versão")
ck("outro título" in pag, "e a edição foi aplicada")
def main() -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
sd_pronta()
pendencia_de_semanas()
teto_de_28_dias()
po_e_objetivo()
sem_datas()
dado_invalido()
entrada_ruim()
writeback()
linha_de_os_no_formulario()
editar_depois_de_exportar()
print()
if FALHAS:
print(f"{len(FALHAS)} falha(s):")
for f in FALHAS:
print(" ·", f)
return 1
print("ponta a ponta: tudo passou.")
return 0
if __name__ == "__main__":
sys.exit(main())