Aplicação Flask (enviar SD.md -> corrigir pendências -> exportar JSON), scripts de contrato (scripts/) e infraestrutura de deploy (Dockerfile, docker-compose.yml) para VPS via Coolify. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
530 lines
27 KiB
Python
530 lines
27 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
teste_fluxo.py — prova que o exportador cumpre o contrato da carga SEM mudar a
|
||
estrutura do JSON, e que a aplicação web e a CLI são a MESMA régua.
|
||
|
||
O que está fixado aqui, na ordem em que custa mais errar:
|
||
|
||
1. ESTRUTURA — o JSON novo tem exatamente as chaves, na mesma ordem, do JSON
|
||
que o importador já consome (scripts/backlog/sd/P7-SD31.json). A rotina
|
||
de importação depende disso; só VALORES podem mudar.
|
||
2. CONTRATO — cada SD que exporta cumpre CONTRATO-JSON-V2.md: linha de OS
|
||
preenchida, UST = time-box × semanas(datas), valor = UST × tarifa, janela
|
||
≤ 28 dias, vocabulário exato da §6, unicidades da §8.
|
||
3. PENDÊNCIAS — as SDs que NÃO exportam ficam pendentes pelos motivos certos,
|
||
campo a campo, e nenhuma outra.
|
||
4. MESMA RÉGUA — o caminho da app (ruamel) e o da CLI (safe_load) produzem
|
||
as mesmas pendências e, quando exportam, os mesmos bytes.
|
||
5. As peças reaproveitadas de regras_sd continuam valendo (secao, Seção 5,
|
||
tipos do ruamel, definir_secao).
|
||
|
||
Roda sem framework de teste:
|
||
python3 scripts/teste_fluxo.py
|
||
0 = tudo bate; 1 = alguma checagem falhou
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
from datetime import date
|
||
|
||
from caminhos import PROJETOS, RAIZ, sds_reais
|
||
from exporta_sd import ExportacaoReprovada, analisar, montar, serializar
|
||
from regras_sd import (JANELA_MAXIMA_DIAS, PERFIS_DO_CADASTRO, STATUS_ENTREGAVEL, STATUS_SD,
|
||
carregar_canonico, definir_secao, enquadramento_tr, ler_sd_arquivo,
|
||
ler_sd_texto, secao, semanas_por_datas)
|
||
|
||
FALHAS: list[str] = []
|
||
|
||
# O que cada SD do acervo deve produzir HOJE. Chave = pasta da SD; valor = as
|
||
# pendências esperadas como (escopo, indice, campo) — lista vazia = exporta.
|
||
# Não é silenciamento: a suíte segue vermelha para qualquer pendência NOVA ou
|
||
# para uma esperada que sumir sem o SD.md ter mudado.
|
||
def _sem_linha(n: int) -> list[tuple]:
|
||
"""SD cujos n entregáveis ainda não declaram `linha_os` no SD.md."""
|
||
return [("entregavel", i, "linha_os") for i in range(n)]
|
||
|
||
|
||
ESPERADO = {
|
||
"SD8-mvp-vacina-em-dia": [("entregavel", 1, "semanas"), ("entregavel", 2, "semanas"),
|
||
("entregavel", 7, "fim")],
|
||
"SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")] + _sem_linha(5),
|
||
"SD18-integracao-core-rnds-samu": _sem_linha(4),
|
||
"SD20-siscan-d1": [("sd", None, "po_responsavel")]
|
||
+ [("entregavel", i, c) for i in range(5) for c in ("inicio", "fim")],
|
||
"SD24-mvp-institucional": [("entregavel", 3, "semanas")],
|
||
"SD27-design-thinking-oficina-superintendencias": _sem_linha(3),
|
||
"SD31-fundacao-da-base-de-dispensacao": _sem_linha(4),
|
||
"SD32-extracao-automatizada-d-1-do-sigaf": _sem_linha(4),
|
||
"SD33-painel-institucional-de-dispensacao": _sem_linha(3),
|
||
"SD34-pacientes-ativos-e-alerta-de-inativacao": _sem_linha(3),
|
||
"SD35-programacao-estoque-e-faltas": _sem_linha(2),
|
||
"SD36-analise-de-processos-ceaf-com-ia-piloto": _sem_linha(3),
|
||
}
|
||
|
||
# O que a pessoa escreveria hoje em `linha_os`, copiado da tabela §5 do contrato
|
||
# (banco de 2026-09-03). NÃO é de-para do exportador — ele não tem nenhum, por
|
||
# decisão da gestão. Serve só para os testes de contrato e de estrutura rodarem
|
||
# sobre um payload completo, como se a SD já estivesse preenchida.
|
||
LINHA_DE_TESTE = {"I-02": "1090-L1", "I-03": "2587-L1", "I-04": "2587-L2", "I-05": "2587-L3"}
|
||
|
||
|
||
def com_linhas(sd: dict) -> dict:
|
||
"""A SD como ficaria depois de a pessoa preencher `linha_os` no formulário."""
|
||
for e in sd.get("entregaveis") or []:
|
||
if isinstance(e, dict) and not e.get("linha_os"):
|
||
e["linha_os"] = LINHA_DE_TESTE.get(e.get("item"), "1090-L1")
|
||
return sd
|
||
|
||
# Regras do sd-schema.json que NÃO são do contrato da carga — a decisão da
|
||
# gestão (2026-09-03) é deixar o importador rejeitar texto fora do teto, e a
|
||
# régua de 3..5 bullets de aderência é redação do repositório, não da carga.
|
||
# O schema continua sendo conferido aqui, por estrutura; estas ficam nomeadas.
|
||
SCHEMA_CONHECIDO = {
|
||
"SD32-extracao-automatizada-d-1-do-sigaf":
|
||
"o bloco I-02 da Seção 5 tem 2 bullets de aderência e o schema pede 3..5 — regra de "
|
||
"redação do repositório, fora do contrato da carga. O exportador não bloqueia por ela.",
|
||
}
|
||
|
||
|
||
def checar(condicao: bool, descricao: str) -> None:
|
||
print(f" {'ok ' if condicao else 'FALHA'} {descricao}")
|
||
if not condicao:
|
||
FALHAS.append(descricao)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. Estrutura
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _esqueleto(o, pref=""):
|
||
"""Todas as listas de chaves do JSON, por caminho — ordem incluída."""
|
||
saida = []
|
||
if isinstance(o, dict):
|
||
saida.append((pref, list(o.keys())))
|
||
for k, v in o.items():
|
||
saida += _esqueleto(v, f"{pref}/{k}")
|
||
elif isinstance(o, list) and o:
|
||
saida += _esqueleto(o[0], pref + "[]")
|
||
return saida
|
||
|
||
|
||
def _achatar(o, p=""):
|
||
if isinstance(o, dict):
|
||
for k, v in o.items():
|
||
yield from _achatar(v, f"{p}/{k}")
|
||
elif isinstance(o, list):
|
||
for i, v in enumerate(o):
|
||
yield from _achatar(v, f"{p}[{i}]")
|
||
else:
|
||
yield p, o
|
||
|
||
|
||
# O que o contrato PEDIU que mudasse de valor. Qualquer outro campo diferente
|
||
# entre o JSON antigo e o novo é regressão na promessa "estrutura e valores
|
||
# iguais, só o que o contrato exige muda".
|
||
VALORES_QUE_O_CONTRATO_MUDOU = re.compile(
|
||
r"/entregaveis\[\d+\]/(ordem_servico/linha|alocacoes\[\d+\]/perfil"
|
||
r"|horas_semanais|ust|valor_unitario_ust|valor_entregavel|memoria_calculo)$"
|
||
r"|^/totais/(ust_total|ust_por_item(/.*)?|valor_total)$")
|
||
|
||
# Chaves cuja PRESENÇA já era condicional no exportador antigo, ou que dependem
|
||
# do conteúdo: comparar a lista delas contra um JSON antigo não diz nada sobre
|
||
# estrutura. `valor_total` só saía com preço em todos os itens; `ust_por_item`
|
||
# tem uma chave por item da SD — hoje sai {} porque a UST é do importador.
|
||
CAMINHOS_DE_CONTEUDO = ("/totais", "/totais/ust_por_item")
|
||
|
||
|
||
def estrutura_intacta(canon) -> None:
|
||
print("\n· ESTRUTURA: o JSON novo tem as mesmas chaves, na mesma ordem, do que o importador já lê")
|
||
pares = 0
|
||
for f in sds_reais():
|
||
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
|
||
ref_path = RAIZ / "scripts/backlog/sd" / (str(sd.get("sd", "")).replace("·", "-") + ".json")
|
||
if not ref_path.exists() or analisar(com_linhas(sd), corpo, canon)[1]:
|
||
continue # sem JSON antigo para comparar, ou pendente por outro motivo
|
||
pares += 1
|
||
referencia = json.loads(ref_path.read_text(encoding="utf-8"))
|
||
novo = json.loads(serializar(montar(sd, corpo, canon)))
|
||
ref, atual = dict(_esqueleto(referencia)), dict(_esqueleto(novo))
|
||
for c in CAMINHOS_DE_CONTEUDO:
|
||
ref.pop(c, None), atual.pop(c, None)
|
||
divergem = [c for c in sorted(set(ref) | set(atual)) if ref.get(c) != atual.get(c)]
|
||
checar(set(novo["totais"]) <= {"ust_total", "ust_por_item", "prazo_calendario_semanas", "moeda", "valor_total"},
|
||
f"{f.parent.name}: totais só com as chaves de sempre")
|
||
checar(not divergem, f"{f.parent.name}: mesmas chaves, na mesma ordem, em "
|
||
f"{len(ref)} caminhos" + (f" — divergem: {divergem}" if divergem else ""))
|
||
a, b = dict(_achatar(referencia)), dict(_achatar(novo))
|
||
mudados = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k))
|
||
fora = [k for k in mudados if not VALORES_QUE_O_CONTRATO_MUDOU.search(k)]
|
||
# Os JSONs do backlog são regravados pelo próprio uso da app; quando a
|
||
# referência já é uma exportação nova, `mudados` fica vazio e isso é o
|
||
# esperado. O que reprova é diferença FORA do que o contrato pediu.
|
||
checar(not fora,
|
||
f"{f.parent.name}: nenhum valor mudou fora de linha de OS e nome de perfil "
|
||
f"({len(mudados)} campos diferentes)" + (f" — FORA: {fora[:5]}" if fora else ""))
|
||
checar(pares >= 6, f"{pares} pares antigo × novo comparados (P7-SD31 e demais do backlog)")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. Contrato
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def cumpre_contrato(nome: str, payload: dict, canon) -> None:
|
||
sd = payload["sd"]
|
||
ents = payload["entregaveis"]
|
||
problemas = []
|
||
if sd["status"] not in STATUS_SD:
|
||
problemas.append(f"sd.status {sd['status']!r}")
|
||
for chave in ("numero_sequencial", "nome", "objetivo", "inicio", "fim"):
|
||
if sd.get(chave) in (None, ""):
|
||
problemas.append(f"sd.{chave} vazio")
|
||
if not sd["po_responsavel"]["nome"]:
|
||
problemas.append("po vazio")
|
||
if not re.match(r"^V\d+$", payload["_governanca"]["versao_documento"] or ""):
|
||
problemas.append("versao_documento")
|
||
if not ents:
|
||
problemas.append("sem entregáveis")
|
||
ns = [e["n"] for e in ents]
|
||
if len(ns) != len(set(ns)):
|
||
problemas.append("n repetido")
|
||
for e in ents:
|
||
a, b = date.fromisoformat(e["data_inicio"]), date.fromisoformat(e["data_prevista_termino"])
|
||
dias = (b - a).days
|
||
if dias < 0 or dias > JANELA_MAXIMA_DIAS:
|
||
problemas.append(f"n{e['n']} janela {dias}d")
|
||
sem = semanas_por_datas(a, b)
|
||
if e["numero_semanas"] != sem:
|
||
problemas.append(f"n{e['n']} semanas {e['numero_semanas']} ≠ {sem}")
|
||
# Caminho A: UST, tarifa e valor são do importador. Saem null, sempre —
|
||
# um número aqui seria o exportador voltando a copiar tabela alheia.
|
||
for chave in ("ust", "valor_unitario_ust", "valor_entregavel", "horas_semanais", "memoria_calculo"):
|
||
if chave in e and e[chave] is not None:
|
||
problemas.append(f"n{e['n']} {chave} deveria ser null")
|
||
if not re.match(r"^\d+-L\d+$", e["ordem_servico"]["linha"] or ""):
|
||
problemas.append(f"n{e['n']} linha {e['ordem_servico']['linha']!r}")
|
||
if e["status"] not in STATUS_ENTREGAVEL:
|
||
problemas.append(f"n{e['n']} status {e['status']!r}")
|
||
if e["item"] not in sd["itens_contratuais"]:
|
||
problemas.append(f"n{e['n']} item fora de itens_contratuais")
|
||
for al in e["alocacoes"]:
|
||
if al["perfil"] not in PERFIS_DO_CADASTRO:
|
||
problemas.append(f"n{e['n']} perfil {al['perfil']!r}")
|
||
for lista in ("backlog", "criterios_aceite"):
|
||
ordens = [x["ordem"] for x in e[lista]]
|
||
if ordens != list(range(1, len(ordens) + 1)) or any(not x["descricao"] for x in e[lista]):
|
||
problemas.append(f"n{e['n']} {lista}")
|
||
nomes = [d["nome"] for d in e["documentacao"]]
|
||
if len(nomes) != len(set(nomes)) or any(not x for x in nomes):
|
||
problemas.append(f"n{e['n']} documentacao")
|
||
checar(not problemas, f"{nome}: cumpre o contrato" + (f" — {problemas}" if problemas else ""))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3 + 4. Pendências esperadas, mesma régua nos dois caminhos
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def acervo(canon) -> None:
|
||
print("\n· PENDÊNCIAS: cada SD do acervo reprova (ou não) pelos motivos esperados")
|
||
print("· MESMA RÉGUA: app (ruamel) × CLI (safe_load), pendências e bytes iguais")
|
||
vistos = set()
|
||
for f in sds_reais():
|
||
nome = f.parent.name
|
||
vistos.add(nome)
|
||
sd_cli, corpo_cli = ler_sd_arquivo(f)
|
||
sd_app, corpo_app = ler_sd_arquivo(f, preservar_comentarios=True)
|
||
p_cli, pend_cli = analisar(sd_cli, corpo_cli, canon)
|
||
p_app, pend_app = analisar(sd_app, corpo_app, canon)
|
||
assinatura = [(p.escopo, p.indice, p.campo) for p in pend_app]
|
||
|
||
esperado = ESPERADO.get(nome)
|
||
if esperado is None:
|
||
checar(False, f"{nome}: SD nova no acervo — declare o esperado em ESPERADO")
|
||
else:
|
||
checar(sorted(assinatura, key=str) == sorted(esperado, key=str),
|
||
f"{nome}: pendências {assinatura or 'nenhuma'}")
|
||
checar([str(p) for p in pend_cli] == [str(p) for p in pend_app],
|
||
f"{nome}: mesmas pendências nos dois caminhos")
|
||
if pend_app:
|
||
try:
|
||
montar(sd_app, corpo_app, canon)
|
||
checar(False, f"{nome}: montar() gerou payload com pendência aberta")
|
||
except ExportacaoReprovada as exc:
|
||
checar(len(exc.pendencias) == len(pend_app), f"{nome}: montar() levanta as mesmas pendências")
|
||
|
||
# Como a SD fica depois de a pessoa preencher `linha_os`: se só faltava
|
||
# isso, o payload completo tem de cumprir o contrato e sair igual nos
|
||
# dois caminhos.
|
||
com_linhas(sd_cli), com_linhas(sd_app)
|
||
if analisar(sd_app, corpo_app, canon)[1]:
|
||
continue # pendente por outro motivo — coberto acima
|
||
s_cli = serializar(montar(sd_cli, corpo_cli, canon))
|
||
s_app = serializar(montar(sd_app, corpo_app, canon))
|
||
checar(s_cli == s_app, f"{nome}: mesmos bytes (com linha_os preenchido)")
|
||
cumpre_contrato(nome, json.loads(s_app), canon)
|
||
for nome in ESPERADO:
|
||
if nome not in vistos:
|
||
checar(False, f"{nome}: consta em ESPERADO mas não existe mais no acervo")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Regras do contrato que o acervo não exercita
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_CORPO_OK = "# T\n\n## 1. Objetivo\n\nFazer algo útil.\n\n## 5. Enquadramento\n\n"
|
||
|
||
|
||
def _sd(entregaveis: str, topo: str = "") -> tuple[dict, str]:
|
||
fm = ("---\n"
|
||
'sd: "P9·SD99"\n'
|
||
'projeto: "P9 · Teste"\n'
|
||
'titulo: "SD sintética"\n'
|
||
'po_responsavel: "Alguém do Cadastro"\n'
|
||
'versao: "V1"\n'
|
||
"estado: rascunho\n"
|
||
+ topo
|
||
+ "entregaveis:\n" + entregaveis + "---\n")
|
||
return ler_sd_texto(fm + _CORPO_OK, preservar_comentarios=True)
|
||
|
||
|
||
def _pend(canon, entregaveis: str, topo: str = "") -> list[tuple]:
|
||
sd, corpo = _sd(entregaveis, topo)
|
||
return [(p.escopo, p.indice, p.campo) for p in analisar(sd, corpo, canon)[1]]
|
||
|
||
|
||
ENT_SEM_LINHA = (' - n: 1\n nome: "Um"\n tipo: Descoberta\n item: I-02\n'
|
||
" inicio: 2026-09-01\n fim: 2026-09-11\n")
|
||
ENT_OK = ENT_SEM_LINHA + ' linha_os: "1090-L1"\n'
|
||
|
||
|
||
def regras_sinteticas(canon) -> None:
|
||
print("\n· CONTRATO: regras que o acervo de hoje não tripa, em frontmatter sintético")
|
||
checar(_pend(canon, ENT_OK) == [], "o caso-base sintético exporta sem pendência")
|
||
|
||
def caso(desc, entregaveis, esperado, topo=""):
|
||
got = _pend(canon, entregaveis, topo)
|
||
checar(sorted(got, key=str) == sorted(esperado, key=str), f"{desc}: {got}")
|
||
|
||
caso("linha_os ausente", ENT_SEM_LINHA, [("entregavel", 0, "linha_os")])
|
||
caso("linha_os nula", ENT_SEM_LINHA + " linha_os: null\n", [("entregavel", 0, "linha_os")])
|
||
caso("linha_os fora do formato", ENT_SEM_LINHA + ' linha_os: "L1"\n', [("entregavel", 0, "linha_os")])
|
||
# Só formato: existência, item e status são do banco, na carga. Uma linha
|
||
# que o repositório não conhece PASSA aqui, de propósito.
|
||
checar(_pend(canon, ENT_SEM_LINHA + ' linha_os: "9999-L7"\n') == [],
|
||
"linha_os bem formada que o repositório não conhece passa — quem confere é o banco")
|
||
caso("I-01 (Licença) não entra pela carga", ENT_OK.replace("I-02", "I-01"), [("entregavel", 0, "item")])
|
||
caso("item desconhecido", ENT_OK.replace("I-02", "I-07"), [("entregavel", 0, "item")])
|
||
caso("tipo Manutenção para a carga", ENT_OK.replace("Descoberta", "Manutenção"), [("entregavel", 0, "tipo")])
|
||
caso("tipo fora do dicionário", ENT_OK.replace("Descoberta", "Discovery"), [("entregavel", 0, "tipo")])
|
||
caso("`n` repetido", ENT_OK + ENT_OK.replace('nome: "Um"', 'nome: "Dois"'), [("entregavel", 1, "n")])
|
||
caso("`n` zero", ENT_OK.replace("n: 1", "n: 0"), [("entregavel", 0, "n")])
|
||
caso("sem nome", ENT_OK.replace('nome: "Um"', 'nome: ""'), [("entregavel", 0, "nome")])
|
||
caso("UST declarada no .md (é do importador)", ENT_OK + " ust: 80\n", [("entregavel", 0, "ust")])
|
||
caso("fim antes do início", ENT_OK.replace("fim: 2026-09-11", "fim: 2026-08-30"), [("entregavel", 0, "fim")])
|
||
caso("dia inexistente", ENT_OK.replace("fim: 2026-09-11", 'fim: "2026-02-30"'), [("entregavel", 0, "fim")])
|
||
caso("janela de 29 dias", ENT_OK.replace("fim: 2026-09-11", "fim: 2026-09-30"), [("entregavel", 0, "fim")])
|
||
checar(_pend(canon, ENT_OK.replace("fim: 2026-09-11", "fim: 2026-09-29")) == [],
|
||
"janela de exatamente 28 dias passa (4 semanas)")
|
||
caso("documentação com nome repetido",
|
||
ENT_OK + ' artefatos:\n - nome: "Doc"\n - nome: "Doc"\n', [("entregavel", 0, "artefatos")])
|
||
caso("documentação sem nome", ENT_OK + ' artefatos:\n - nome: ""\n', [("entregavel", 0, "artefatos")])
|
||
caso("item vazio no backlog", ENT_OK + ' atividades:\n - "a"\n - ""\n', [("entregavel", 0, "atividades")])
|
||
caso("alocacoes_desvio com perfil fora do cadastro",
|
||
ENT_OK + ' alocacoes_desvio:\n - {perfil: "Mago", quantidade: 1, percentual: 50}\n',
|
||
[("entregavel", 0, "alocacoes_desvio")])
|
||
caso("alocacoes_desvio com percentual > 100",
|
||
ENT_OK + ' alocacoes_desvio:\n - {perfil: "Scrum Master", quantidade: 1, percentual: 150}\n',
|
||
[("entregavel", 0, "alocacoes_desvio")])
|
||
sd, corpo = _sd(ENT_OK)
|
||
sd["estado"] = "voando"
|
||
checar([(p.escopo, p.campo, bool(p.opcoes)) for p in analisar(sd, corpo, canon)[1]]
|
||
== [("sd", "estado", True)], "estado desconhecido: pendência com a lista de opções")
|
||
sd, corpo = _sd(ENT_OK)
|
||
sd["versao"] = "1.0"
|
||
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "versao")],
|
||
"versão fora de V{n}")
|
||
sd, corpo = _sd(ENT_OK)
|
||
sd["sd"] = "SD99"
|
||
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "sd")],
|
||
"identificador fora de P{n}·SD{n}")
|
||
sd, corpo = _sd(ENT_OK)
|
||
sd["dependencias"] = ["solta"]
|
||
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "dependencias")],
|
||
"dependencias em lista solta")
|
||
corpo_ruim = _CORPO_OK + "| **Item do TR** | I-09 — não existe |\n"
|
||
sd, _ = _sd(ENT_OK)
|
||
checar([(p.escopo, p.campo) for p in analisar(sd, corpo_ruim, canon)[1]] == [("corpo", "secao5")],
|
||
"Seção 5 com item fora do canônico")
|
||
|
||
# O caminho positivo do que é raro no acervo: linha declarada (1091-L1, a
|
||
# segunda OS de I-02) e alocacoes_desvio normalizada.
|
||
sd, corpo = _sd(ENT_SEM_LINHA + ' linha_os: "1091-L1"\n'
|
||
' alocacoes_desvio:\n'
|
||
' - {perfil: "Especialista de infraestrutura", quantidade: 2, percentual: 50}\n')
|
||
e = montar(sd, corpo, canon)["entregaveis"][0]
|
||
checar(e["ordem_servico"]["linha"] == "1091-L1", "linha_os declarada vai como está para o JSON")
|
||
checar(e["alocacoes"] == [{"perfil": "Especialista de Infraestrutura", "quantidade": 2,
|
||
"percentual_alocacao": 50}],
|
||
"alocacoes_desvio sai com o nome do cadastro e a chave percentual_alocacao")
|
||
checar(e["numero_semanas"] == 2 and e["ust"] is None and e["valor_unitario_ust"] is None
|
||
and e["valor_entregavel"] is None and e["horas_semanais"] is None,
|
||
"11 dias → 2 semanas; UST, tarifa e valor saem null — o importador deriva (§11 do contrato)")
|
||
checar(list(e) == ["n", "codigo", "titulo", "tipo_entrega", "item", "ordem_servico", "data_inicio",
|
||
"data_prevista_termino", "numero_semanas", "status", "horas_semanais", "ust",
|
||
"valor_unitario_ust", "valor_entregavel", "memoria_calculo", "alocacoes",
|
||
"backlog", "criterios_aceite", "documentacao"],
|
||
"as chaves do entregável ficam, na mesma ordem, mesmo com os valores em null")
|
||
for estado, sd_status, ent_status in (("emitida", "Em andamento", "Emitido"),
|
||
("validado", "Entregue", "Aprovado"),
|
||
("bloqueada", "Planejado", "Rascunho")):
|
||
sd, corpo = _sd(ENT_OK)
|
||
sd["estado"] = estado
|
||
p = montar(sd, corpo, canon)
|
||
checar(p["sd"]["status"] == sd_status and p["entregaveis"][0]["status"] == ent_status,
|
||
f"estado {estado} → sd.status {sd_status!r}, entregável {ent_status!r}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Schema — por estrutura
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def schema_valida(canon) -> None:
|
||
"""Todo JSON que sai precisa validar contra o sd-schema.json.
|
||
|
||
O schema deixou de ser o portão do download (o contrato da carga é), mas
|
||
continua descrevendo a ESTRUTURA — additionalProperties: false na raiz e em
|
||
_governanca. Se um campo novo aparecer, ele acusa aqui.
|
||
"""
|
||
print("\n· JSON exportado valida contra sd-schema.json (estrutura)")
|
||
try:
|
||
import jsonschema
|
||
except ImportError:
|
||
checar(False, "jsonschema não instalado — pip install jsonschema")
|
||
return
|
||
esquema = json.loads((PROJETOS / "_template" / "sd-schema.json").read_text(encoding="utf-8"))
|
||
for f in sds_reais():
|
||
nome = f.parent.name
|
||
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
|
||
if analisar(com_linhas(sd), corpo, canon)[1]:
|
||
continue # não exporta nem com linha_os preenchido — não há JSON a validar
|
||
queixa = None
|
||
try:
|
||
jsonschema.validate(json.loads(serializar(montar(sd, corpo, canon))), esquema)
|
||
except jsonschema.ValidationError as exc:
|
||
caminho = "/".join(str(p) for p in exc.absolute_path) or "(raiz)"
|
||
queixa = f"{caminho} — {exc.message[:120]}"
|
||
conhecida = SCHEMA_CONHECIDO.get(nome)
|
||
if queixa and conhecida:
|
||
print(f" aviso {nome}: {queixa}")
|
||
print(f" └ conhecido: {conhecida}")
|
||
elif queixa:
|
||
checar(False, f"{nome}: {queixa}")
|
||
elif conhecida:
|
||
checar(False, f"{nome}: a divergência conhecida foi resolvida — tire de SCHEMA_CONHECIDO")
|
||
else:
|
||
checar(True, f"{nome}: valida")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. Peças de regras_sd
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def tipos_do_ruamel() -> None:
|
||
print("\n· Tipos que o ruamel devolve (as regras dependem deles)")
|
||
amostra = ("---\n"
|
||
"redigida: false\n"
|
||
"formalizacao: {sd_emitida: false, os_mae: {I-02: true}}\n"
|
||
"semanas: 3\n"
|
||
"data_abertura: 2026-08-11\n"
|
||
"---\ncorpo\n")
|
||
d, corpo = ler_sd_texto(amostra, preservar_comentarios=True)
|
||
checar(d["redigida"] is False, "`redigida: false` continua sendo o singleton False")
|
||
checar(d["formalizacao"]["os_mae"]["I-02"] is True, "`os_mae: true` é o singleton True")
|
||
checar(isinstance(d["semanas"], int), "`semanas` passa no isinstance(int)")
|
||
checar(isinstance(d["data_abertura"], date), "data crua vira datetime.date")
|
||
checar(isinstance(d, dict), "o mapa é subclasse de dict")
|
||
checar(corpo.strip() == "corpo", "o corpo sai separado do frontmatter")
|
||
checar("_corpo" not in d, "o frontmatter NÃO carrega `_corpo`")
|
||
|
||
|
||
def secoes() -> None:
|
||
print("\n· secao() e definir_secao() são a régua única para as seções do corpo")
|
||
corpo_torto = "# t\n\n## 5.Enquadramento no TR\n\ntexto\n"
|
||
checar(secao(corpo_torto, "5") is None, "`## 5.Enquadramento` (sem espaço) não é Seção 5")
|
||
corpo_ok = "# t\n\n## 5. Enquadramento no TR\n\ntexto\n"
|
||
checar(secao(corpo_ok, "5") == "texto", "`## 5. Enquadramento` é reconhecido")
|
||
|
||
# A P2·SD10 não tem Seção 1: o objetivo entra antes da 5, e as demais ficam.
|
||
sd10 = "# Título\n\n## 5. Enquadramento\n\ntabela\n\n## 6. Fora de escopo\n\nfora\n"
|
||
novo = definir_secao(sd10, "1", "Objetivo escrito na tela.")
|
||
checar(secao(novo, "1") == "Objetivo escrito na tela.", "Seção 1 criada quando não existia")
|
||
checar(novo.index("## 1.") < novo.index("## 5."), "criada ANTES da Seção 5, na ordem do template")
|
||
checar(secao(novo, "5") == "tabela" and secao(novo, "6") == "fora", "as outras seções ficam intactas")
|
||
# Existente: só o conteúdo muda; o cabeçalho e o resto ficam.
|
||
com1 = "# T\n\n## 1. Objetivo da SD\n\nvelho\ntexto\n\n## 2. Contexto\n\nctx\n"
|
||
novo = definir_secao(com1, "1", "novo")
|
||
checar(secao(novo, "1") == "novo" and "## 1. Objetivo da SD" in novo and secao(novo, "2") == "ctx",
|
||
"Seção 1 existente: conteúdo trocado, cabeçalho e Seção 2 preservados")
|
||
|
||
|
||
def enquadramento_da_secao5(canon) -> None:
|
||
print("\n· enquadramento_tr() lê a tabela da Seção 5 nas formas do acervo")
|
||
itens = canon.itens["itens"]
|
||
|
||
def s5(corpo):
|
||
return enquadramento_tr(secao(corpo, "5"), itens)
|
||
|
||
cabeca = "## 5. Enquadramento no Termo de Referência\n\n| | |\n|---|---|\n"
|
||
corpo = (cabeca
|
||
+ "| **Item do TR** | I-02 — Desenvolvimento e implantação |\n"
|
||
+ "| **Descrição do item** | Conforme TR 1.6.4.1 |\n"
|
||
+ "| **Aderência desta SD** | • um<br>• dois<br>• três |\n")
|
||
b = s5(corpo)
|
||
checar(len(b) == 1 and b[0]["item"] == "I-02", "célula com <br>: uma entrada, item I-02")
|
||
checar(b[0]["aderencia"] == ["um", "dois", "três"], "os '•' viram lista, sem marcador residual")
|
||
corpo = (cabeca
|
||
+ "| **Item do TR** | I-05 — \"Serviços Técnicos\" |\n"
|
||
+ "| **Aderência desta SD** | ver bullets abaixo |\n\n"
|
||
+ "- primeiro bullet que quebra\n em duas linhas\n"
|
||
+ "- **Avaliação Técnica** — com negrito\n- terceiro\n\nFecho.\n")
|
||
b = s5(corpo)
|
||
checar(b[0]["aderencia"] == ["primeiro bullet que quebra em duas linhas",
|
||
"**Avaliação Técnica** — com negrito", "terceiro"],
|
||
"indentação pendurada recolada, negrito intacto, fecho fora")
|
||
corpo = cabeca + "| **Item do TR** | I-09 — item que não existe |\n"
|
||
try:
|
||
s5(corpo)
|
||
checar(False, "item fora do canônico levanta ValueError")
|
||
except ValueError as exc:
|
||
checar("I-09" in str(exc), "o erro nomeia o item inválido")
|
||
|
||
|
||
def main() -> int:
|
||
for stream in (sys.stdout, sys.stderr):
|
||
if hasattr(stream, "reconfigure"):
|
||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||
|
||
canon = carregar_canonico()
|
||
estrutura_intacta(canon)
|
||
acervo(canon)
|
||
regras_sinteticas(canon)
|
||
schema_valida(canon)
|
||
tipos_do_ruamel()
|
||
secoes()
|
||
enquadramento_da_secao5(canon)
|
||
|
||
print()
|
||
if FALHAS:
|
||
print(f"{len(FALHAS)} falha(s):")
|
||
for f in FALHAS:
|
||
print(f" · {f}")
|
||
return 1
|
||
print("tudo bate.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|