Projeto do Agent Contas ORACLE

This commit is contained in:
2026-08-19 09:35:50 -03:00
commit 950a2bcd33
1366 changed files with 177217 additions and 0 deletions

View File

@@ -0,0 +1,692 @@
"""Suíte ADVERSARIAL do casamento ``invoiceVariation`` → ``invoice_detail``.
Contraexemplos difíceis para uma **futura** camada de similaridade. Nada aqui liga
similaridade no fluxo real: os 50 casos fixam o que o matcher determinístico faz hoje
e medem, em **shadow**, o score que uma camada de similaridade veria — para responder
*antes* de mexer em produção se existe threshold que separe com segurança.
Assimetria que governa o desenho: nesta rota a fila de cancelamento é autorizada **sem
confirmação pendente** (``runtime.py`` ``_handle_execute_action_queue``), então um falso
positivo não vira pergunta ao cliente — vira **cancelamento executado**. Logo::
FALSO POSITIVO >> FALSO NEGATIVO na dúvida, NÃO CASAR
Os nomes de serviço são reais do repositório (fixtures/cenários): ``Fluid Light`` ×
``Fluid Premium``, ``Aya Books Premium`` × ``Aya Audiobooks Premium``, ``EXA Cloud
500GB`` × ``EXA Segurança Premium``, ``Fit Me App``. Colisões inventadas seriam mais
fáceis que a realidade.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
import pytest
from app.domain.contas.invoice_resolver import InvoiceResolver
from app.domain.contas.item_matcher import SimilarityItemMatcher
from app.domain.contas.vas_variation import varied_avulso_items
CURRENT = "2026-04-07T00:00:00.000Z"
LINE_A = "11999999999"
LINE_B = "11888888888"
# Seções por classe resultante (``invoice_resolver.SECTION_DEFAULTS``).
AVULSO = "SVA Detalhe Total"
EVENTUAIS = "Itens Eventuais"
ESTRATEGICO = "Serviços Contratados de Terceiros"
BUNDLE = "Serviços Bundle Inclusos"
# Fora de SECTION_DEFAULTS → o item existe na fatura mas não é acionável.
SEM_CLASSE = "Mensalidades Adicionais"
# ----- modelo dos casos -------------------------------------------------------
@dataclass(frozen=True)
class Entry:
"""Uma linha de item no ``invoice_detail`` (PDF parseado)."""
desc: str
value: str
section: str = AVULSO
msisdn: str = LINE_A
@dataclass(frozen=True)
class Case:
"""Um contraexemplo.
``expected`` são os ``canonical_name`` que o matcher PODE devolver como
canceláveis. Tupla vazia = **falha fechada** (o desfecho seguro por default)."""
case_id: str
category: str
var_desc: str
var_value: str
invoice: tuple[Entry, ...]
expected: tuple[str, ...]
why: str
# Linha esperada do item casado, quando o caso testa roteamento de msisdn.
expected_msisdn: str | None = None
# ----- builders ---------------------------------------------------------------
def _variation(desc: str, value: str) -> dict:
"""``invoiceVariation`` de uma cobrança que ENTROU na fatura atual.
Um único vencimento no bloco de variação, desempatado por ``currentInvoice`` —
a forma que o runtime entrega quando a fatura passada não tinha o avulso."""
item = {
"type": "servicos_contratados_de_parceiros",
"desc": "Serviços de valor adicionado",
"nItems": "1",
"items": [{"desc": desc, "value": value, "invoice": CURRENT}],
}
current = {
"type": "servicos_contratados_de_parceiros",
"desc": "Serviços de valor adicionado",
"items": [{"desc": desc, "value": value, "contestable": True}],
}
return {"invoiceVariation": [item], "currentInvoice": [current]}
def _detail(entries: tuple[Entry, ...]) -> dict:
"""``invoice_detail`` no formato ``{msisdn: {seção: [entries]}}``."""
buckets: dict[str, dict[str, list]] = {}
for entry in entries:
section = buckets.setdefault(entry.msisdn, {}).setdefault(entry.section, [])
section.append({"desc": entry.desc, "value": entry.value})
return buckets
def _run(case: Case):
"""Executa o matcher REAL de produção sobre o caso."""
return varied_avulso_items(
invoice_variation=_variation(case.var_desc, case.var_value),
invoice_detail=_detail(case.invoice),
resolver=InvoiceResolver(),
by_charge=False,
)
# ----- A. colisões semânticas (15) --------------------------------------------
# Produtos DIFERENTES com nomes quase idênticos e MESMO valor. Similaridade alta
# sozinha nunca pode autorizar cancelamento.
_A: tuple[Case, ...] = (
Case(
"A01-fluid-light-x-premium", "colisao", "Fluid Light", "12.90",
(Entry("Fluid Premium", "12.90"),), (),
"Light × Premium: mesma marca, produto e preço diferentes de propósito",
),
Case(
"A02-fluid-premium-x-light", "colisao", "Fluid Premium", "12.90",
(Entry("Fluid Light", "12.90"),), (),
"inverso de A01 — a direção não pode salvar o casamento",
),
Case(
"A03-aya-books-x-audiobooks", "colisao", "Aya Books Premium", "9.99",
(Entry("Aya Audiobooks Premium", "9.99"),), (),
"um token difere e ele CONTÉM o outro (books ⊂ audiobooks)",
),
Case(
"A04-aya-ensinah-x-books", "colisao", "Aya Ensinah Premium", "9.99",
(Entry("Aya Books Premium", "9.99"),), (),
"2 de 3 tokens iguais, mesmo preço",
),
Case(
"A05-exa-seguranca-x-cloud", "colisao", "EXA Segurança Premium", "19.99",
(Entry("EXA Cloud 500GB", "19.99"),), (),
"mesma marca EXA, serviços sem relação",
),
Case(
"A06-bancah-jornais-x-premium", "colisao", "Bancah Jornais II", "7.99",
(Entry("Bancah Premium + Jornais", "7.99"),), (),
"mesmos tokens em ORDEM diferente — bag-of-words casaria",
),
Case(
"A07-itgame-light-x-premium", "colisao", "ITGame Light", "14.99",
(Entry("ITGame Premium", "14.99"),), (),
"Light × Premium na mesma marca",
),
Case(
"A08-aluguel-filme-1-x-2", "colisao", "Aluguel de Filme 1", "19.90",
(Entry("Aluguel de Filme 2", "19.90"),), (),
"difere só no dígito final — Jaro-Winkler dá score altíssimo",
),
Case(
"A09-tim-games-mensal-x-semanal", "colisao", "TIM Games Mensal", "16.99",
(Entry("TIM Games Semanal", "16.99"),), (),
"Mensal × Semanal: periodicidade diferente é PRODUTO diferente",
),
Case(
"A10-kids-plus-x-premium", "colisao", "Kids Plus", "11.99",
(Entry("Kids Premium", "11.99"),), (),
"Plus × Premium",
),
Case(
"A11-exa-premium-x-protecao", "colisao", "EXA Segurança Premium", "19.99",
(Entry("EXA Segurança + Proteção Stand", "19.99"),), (),
"2 tokens iniciais idênticos, cauda diferente",
),
Case(
"A12-tim-games-prefixo", "colisao", "TIM Games", "16.99",
(Entry("TIM Games Premium", "16.99"),), (),
"PREFIXO DE TOKENS entre produtos DIFERENTES — indistinguível de FIT ME "
"pela forma; só o valor separaria, e aqui ele coincide",
),
Case(
"A13-fit-app-x-fit-me-app", "colisao", "Fit App", "16.99",
(Entry("Fit Me App", "16.99"),), (),
"token inserido no MEIO — não é prefixo, mas a grafia é quase idêntica",
),
Case(
"A14-tim-music-plus-x-premium", "colisao", "Tim Music Plus", "9.90",
(Entry("Tim Music Premium", "9.90"),), (),
"difere só no último token",
),
Case(
"A15-kids-prefixo", "colisao", "Kids", "11.99",
(Entry("Kids Premium", "11.99"),), (),
"segundo prefixo perigoso: nome curto genérico casaria com qualquer coisa",
),
)
# ----- B. diferença mínima de valor (8) ---------------------------------------
# Nome idêntico (ou prefixo perfeito), candidato único, similaridade 1.0 — e o
# valor difere. Um centavo já basta para bloquear.
_B: tuple[Case, ...] = (
Case(
"B01-fitme-16.99-x-17.00", "valor", "Fit Me App", "16.99",
(Entry("Fit Me App", "17.00"),), (),
"nome idêntico, 1 centavo acima",
),
Case(
"B02-fitme-16.99-x-16.98", "valor", "Fit Me App", "16.99",
(Entry("Fit Me App", "16.98"),), (),
"nome idêntico, 1 centavo abaixo",
),
Case(
"B03-fluid-14.99-x-15.00", "valor", "Fluid Premium", "14.99",
(Entry("Fluid Premium", "15.00"),), (),
"cruza a fronteira de unidade",
),
Case(
"B04-exa-9.99-x-9.98", "valor", "EXA Cloud 500GB", "9.99",
(Entry("EXA Cloud 500GB", "9.98"),), (),
"1 centavo em valor baixo",
),
Case(
"B05-fitme-prefixo-valor-errado", "valor", "FIT ME App", "16.99",
(Entry("FIT ME App Premium Mensal", "17.00"),), (),
"OBRIGATÓRIO §5: o caso real, com o valor divergente — prefixo válido não "
"compensa centavo errado",
),
Case(
"B06-aya-24.90-x-24.91", "valor", "Aya Books Premium", "24.90",
(Entry("Aya Books Premium", "24.91"),), (),
"1 centavo com nome longo idêntico",
),
Case(
"B07-itgame-12.90-x-12.09", "valor", "ITGame Light", "12.90",
(Entry("ITGame Light", "12.09"),), (),
"dígitos transpostos — erro de parser plausível",
),
Case(
"B08-fitme-16.99-x-169.90", "valor", "Fit Me App", "16.99",
(Entry("Fit Me App", "169.90"),), (),
"vírgula deslocada — 10× o valor",
),
)
# ----- C. estratégico disfarçado de avulso (7) --------------------------------
# A classificação da FATURA vence a da API. Nome e valor idênticos, candidato
# único, similaridade máxima — e mesmo assim não é cancelável.
_C: tuple[Case, ...] = (
Case(
"C01-hbo-secao-terceiros", "estrategico", "HBO Max Standard", "29.90",
(Entry("HBO Max Standard", "29.90", section=ESTRATEGICO),), (),
"seção de terceiros → item_type=estrategico",
),
Case(
"C02-netflix-em-secao-avulsa", "estrategico", "Netflix Premium", "39.90",
(Entry("Netflix Premium", "39.90", section=AVULSO),), (),
"guard 1: nome na lista fixa §12.A vence a seção avulsa",
),
Case(
"C03-youtube-mis-bucket", "estrategico", "YouTube Premium Mensal", "24.90",
(Entry("YouTube Premium Mensal", "24.90", section=AVULSO),), (),
"caso REAL documentado: a API bucketa streaming como avulso",
),
Case(
"C04-deezer-em-secao-avulsa", "estrategico", "Deezer Premium", "19.90",
(Entry("Deezer Premium", "19.90", section=AVULSO),), (),
"guard 1 sobre marca da lista fixa",
),
Case(
"C05-apple-music-em-secao-avulsa", "estrategico", "Apple Music SVA Mes", "16.90",
(Entry("Apple Music SVA Mes", "16.90", section=AVULSO),), (),
"marca de duas palavras na lista fixa",
),
Case(
"C06-bundle-incluso", "estrategico", "Tamboro Mensal", "19.99",
(Entry("Tamboro Mensal", "19.99", section=BUNDLE),), (),
"bundle incluso no plano: não se cancela item de bundle",
),
Case(
"C07-fitme-classificado-estrategico", "estrategico", "FIT ME App", "16.99",
(Entry("FIT ME App Premium Mensal", "16.99", section=ESTRATEGICO),), (),
"OBRIGATÓRIO §5: o caso real, mas a fatura o classifica estratégico",
),
)
# ----- D. ambiguidade extrema (8) ---------------------------------------------
# 2+ candidatos plausíveis. Nunca escolher o de maior score: falha FECHADA.
_D: tuple[Case, ...] = (
Case(
"D01-fitme-dois-candidatos", "ambiguidade", "FIT ME App", "16.99",
(
Entry("FIT ME App Premium", "16.99"),
Entry("FIT ME App Premium Mensal", "16.99"),
),
(),
"OBRIGATÓRIO §5: dois prefixos válidos, mesmo valor — cancelar o errado "
"custa o serviço de um cliente real",
),
Case(
"D02-fitme-tres-candidatos", "ambiguidade", "FIT ME App", "16.99",
(
Entry("FIT ME App Premium", "16.99"),
Entry("FIT ME App Premium Mensal", "16.99"),
Entry("FIT ME App Premium Anual", "16.99"),
),
(),
"três candidatos: mais opções não tornam a escolha mais segura",
),
Case(
"D03-aya-tres-marcas", "ambiguidade", "Aya", "9.99",
(
Entry("Aya Books Premium", "9.99"),
Entry("Aya Audiobooks Premium", "9.99"),
Entry("Aya Ensinah Premium", "9.99"),
),
(),
"prefixo de marca casa a família inteira",
),
Case(
"D04-mesmo-nome-duas-linhas", "ambiguidade", "Fit Me App", "16.99",
(
Entry("Fit Me App", "16.99", msisdn=LINE_A),
Entry("Fit Me App", "16.99", msisdn=LINE_B),
),
(),
"nome e valor idênticos em DUAS linhas: cancelar na linha errada é dano real",
),
Case(
"D05-duplicata-mesma-linha", "ambiguidade", "Fit Me App", "16.99",
(
Entry("Fit Me App", "16.99"),
Entry("Fit Me App", "16.99"),
),
("Fit Me App",),
"cobrança duplicada na MESMA linha e seção: o dedupe do resolver colapsa "
"em uma, e uma variação consome exatamente uma",
),
Case(
"D06-fluid-familia", "ambiguidade", "Fluid", "12.90",
(
Entry("Fluid Light", "12.90"),
Entry("Fluid Premium", "12.90"),
),
(),
"marca sozinha não identifica o produto",
),
Case(
"D07-exa-familia", "ambiguidade", "EXA", "19.99",
(
Entry("EXA Cloud 500GB", "19.99"),
Entry("EXA Segurança Premium", "19.99"),
),
(),
"dois produtos sem relação sob a mesma marca",
),
Case(
"D08-bancah-familia", "ambiguidade", "Bancah", "7.99",
(
Entry("Bancah Jornais II", "7.99"),
Entry("Bancah Premium + Jornais", "7.99"),
),
(),
"scores praticamente empatados",
),
)
# ----- E. prefixo / substring perigosos (6) -----------------------------------
# Só a direção comprovada é permitida: variação CURTA → fatura QUALIFICADA.
_E: tuple[Case, ...] = (
Case(
"E01-prefixo-invertido", "prefixo", "Fit Me App Premium", "16.99",
(Entry("Fit Me App", "16.99"),), (),
"variação MAIS específica que a fatura: direção proibida",
),
Case(
"E02-substring-no-meio", "prefixo", "Me App", "16.99",
(Entry("Fit Me App Premium Mensal", "16.99"),), (),
"casa no meio do nome — substring solta cancelaria",
),
Case(
"E03-sufixo-games-premium", "prefixo", "Games Premium", "16.99",
(Entry("TIM Games Premium", "16.99"),), (),
"sufixo, não prefixo",
),
Case(
"E04-token-parcial-tube", "prefixo", "Tube Premium", "24.90",
(Entry("YouTube Premium", "24.90"),), (),
"pedaço de token: 'Tube''YouTube' sem ser token",
),
Case(
"E05-token-solto-app", "prefixo", "App", "16.99",
(Entry("Fit Me App", "16.99"),), (),
"token genérico no fim casaria meia fatura",
),
Case(
"E06-fitme-fail-closed-por-padrao", "prefixo", "FIT ME App", "16.99",
(Entry("FIT ME App Premium Mensal", "16.99"),), (),
"OBRIGATÓRIO §5: o caso real do incidente e081812886a. Com "
"TIM_VAS_QUALIFIED_NAME_MATCH desligada (padrão) ele NÃO casa — é o custo "
"aceito de eliminar FP-1: 'FIT ME App''FIT ME App Premium Mensal' e "
"'TIM Games''TIM Games Premium' têm a MESMA forma, e nenhum sinal "
"disponível os separa. Ver test_flag_liga_a_segunda_passada_e_so_isso.",
),
)
# ----- F. invariantes estruturais (6) -----------------------------------------
_F: tuple[Case, ...] = (
Case(
"F01-msisdn-ausente", "estrutural", "Fit Me App", "16.99",
(Entry("Fit Me App", "16.99", msisdn=""),), (),
"sem linha não há como executar o cancelamento: falha fechada",
),
Case(
"F02-item-na-linha-secundaria", "estrutural", "Fit Me App", "16.99",
(
Entry("Fluid Light", "12.90", msisdn=LINE_A),
Entry("Fit Me App", "16.99", msisdn=LINE_B),
),
("Fit Me App",),
"casa na linha CERTA (B), não na primeira do payload",
LINE_B,
),
Case(
"F03-secao-sem-classe", "estrutural", "Fit Me App", "16.99",
(Entry("Fit Me App", "16.99", section=SEM_CLASSE),), (),
"seção fora de SECTION_DEFAULTS → item existe mas não é acionável",
),
Case(
"F04-desc-vazia", "estrutural", "Fit Me App", "16.99",
(Entry("", "16.99"),), (),
"item sem nome não tem identidade: charge_match_key devolve None",
),
Case(
"F05-valor-nao-parseavel", "estrutural", "Fit Me App", "16.99",
(Entry("Fit Me App", "indisponível"),), (),
"valor ilegível no PDF não pode virar cancelamento",
),
Case(
"F06-fatura-sem-o-item", "estrutural", "Fit Me App", "16.99",
(Entry("Fluid Light", "12.90"), Entry("EXA Cloud 500GB", "9.99")), (),
"a API diz que variou, a fatura não tem: falha fechada (SPEC §9)",
),
)
ALL_CASES: tuple[Case, ...] = _A + _B + _C + _D + _E + _F
assert len(ALL_CASES) == 50, f"a suíte exige exatamente 50 casos, tem {len(ALL_CASES)}"
# ----- falsos positivos conhecidos -------------------------------------------
# VAZIO desde o fix dos 4 FPs que esta suíte encontrou (FP-1/FP-1' prefixo de
# tokens, FP-2 ambiguidade de linha, FP-3 msisdn ausente). Mantido como ponto de
# extensão: um caso novo que o matcher viole entra aqui com `strict=True`, e o
# XPASS quebra a suíte no dia em que for corrigido — a lista nunca envelhece em
# silêncio. Enquanto estiver vazia, TODOS os 50 casos passam de verdade.
KNOWN_UNSAFE: dict[str, str] = {}
def _cases_param():
"""Parametrização materializada; compatível com pytest 9/10."""
rows = []
for case in ALL_CASES:
reason = KNOWN_UNSAFE.get(case.case_id)
marks = [pytest.mark.xfail(reason=reason, strict=True)] if reason else []
rows.append(pytest.param(case, id=case.case_id, marks=marks))
return rows
def _shadow_rows() -> list[tuple[Case, float, bool]]:
"""(caso, score de similaridade, deve_casar) para os 50 — sem tocar produção."""
matcher = SimilarityItemMatcher()
rows = []
for case in ALL_CASES:
candidates = [e.desc for e in case.invoice if e.desc]
score = matcher.best_similarity(case.var_desc, candidates) if candidates else 0.0
rows.append((case, score, bool(case.expected)))
return rows
# ----- testes -----------------------------------------------------------------
def test_suite_tem_exatamente_50_casos_com_a_distribuicao_exigida():
"""A distribuição é parte do contrato da suíte, não detalhe."""
from collections import Counter
assert len(ALL_CASES) == 50
assert Counter(c.category for c in ALL_CASES) == {
"colisao": 15,
"valor": 8,
"estrategico": 7,
"ambiguidade": 8,
"prefixo": 6,
"estrutural": 6,
}
assert len({c.case_id for c in ALL_CASES}) == 50, "case_id duplicado"
@pytest.mark.parametrize("case", _cases_param())
def test_matcher_determinístico_nao_produz_falso_positivo(case: Case):
"""O matcher de produção devolve exatamente o conjunto seguro.
Tupla vazia = falha fechada. Qualquer item a mais é um FALSO POSITIVO, e nesta
rota falso positivo é cancelamento executado sem confirmação."""
items = _run(case)
resolved = [] if items is None else [it.canonical_name for it in items]
assert resolved == list(case.expected), (
f"{case.case_id} ({case.category}): esperado {list(case.expected)}, "
f"veio {resolved}{case.why}"
)
if case.expected_msisdn is not None and items:
assert [it.msisdn for it in items] == [case.expected_msisdn], (
f"{case.case_id}: cancelamento roteado para a linha errada"
)
@pytest.mark.parametrize(
"case",
[
pytest.param(
c,
id=c.case_id,
marks=(
[pytest.mark.xfail(reason=KNOWN_UNSAFE[c.case_id], strict=True)]
if c.case_id in KNOWN_UNSAFE
else []
),
)
for c in ALL_CASES
],
)
def test_nenhum_item_devolvido_e_estrategico_ou_bundle(case: Case):
"""Invariante transversal: o que sai do matcher é sempre cancelável.
Independe da categoria — nenhum caminho pode devolver estratégico/bundle,
nem item sem linha de destino."""
items = _run(case) or []
assert all(it.item_type == "avulso" for it in items), (
f"{case.case_id}: item não-avulso na fila de cancelamento"
)
assert all(it.msisdn for it in items), (
f"{case.case_id}: item sem msisdn na fila de cancelamento"
)
def test_valores_sao_decimal_nunca_float():
"""``float`` em dinheiro reintroduz o erro de centavo que a suíte B testa."""
items = _run(_F[1]) or [] # F02: casa na linha secundária
assert items and all(isinstance(it.value, Decimal) for it in items)
def test_flag_liga_a_segunda_passada_e_so_isso(monkeypatch):
"""``TIM_VAS_QUALIFIED_NAME_MATCH`` governa exatamente o prefixo de tokens.
Ligada, o FIT ME volta a casar — e os falsos positivos da MESMA forma
(``TIM Games``→``TIM Games Premium``) voltam junto. É essa inseparabilidade
que justifica o default desligado: a flag não escolhe "casar só o certo",
escolhe aceitar ou recusar a classe inteira."""
from app.domain.contas import vas_variation
fitme = next(c for c in ALL_CASES if c.case_id.startswith("E06"))
tim_games = next(c for c in ALL_CASES if c.case_id.startswith("A12"))
assert _run(fitme) == [] # default: fail closed
assert _run(tim_games) == []
monkeypatch.setattr(vas_variation, "_VAS_QUALIFIED_NAME_MATCH", True)
assert [it.canonical_name for it in _run(fitme) or []] == [
"FIT ME App Premium Mensal"
]
# o falso positivo volta junto — a prova de que não são separáveis
assert [it.canonical_name for it in _run(tim_games) or []] == ["TIM Games Premium"]
def test_flag_ligada_nao_reabre_msisdn_nem_ambiguidade(monkeypatch):
"""A flag reabre SÓ o prefixo de tokens: os guards de msisdn e de ambiguidade
de linha valem nos dois modos."""
from app.domain.contas import vas_variation
monkeypatch.setattr(vas_variation, "_VAS_QUALIFIED_NAME_MATCH", True)
for prefix in ("F01", "D04", "D01"):
case = next(c for c in ALL_CASES if c.case_id.startswith(prefix))
assert _run(case) == [], f"{case.case_id} reaberto com a flag ligada"
# ----- shadow: o que uma camada de similaridade veria -------------------------
def test_shadow_similaridade_tem_overlap_irremovivel():
"""Positivos e negativos se SOBREPÕEM no eixo de similaridade.
Achado central: o menor score de um caso que DEVE casar fica ABAIXO do maior
score de um caso que NÃO pode casar. Nenhum corte único separa os dois lados —
a similaridade não é o discriminante deste fluxo."""
rows = _shadow_rows()
min_pos = min(s for _, s, safe in rows if safe)
max_neg = max(s for _, s, safe in rows if not safe)
assert max_neg >= min_pos, (
"sem overlap — se isto falhar, a suíte perdeu seus contraexemplos duros "
"e precisa de casos novos antes de servir como evidência"
)
# Ancorado no medido: negativos chegam a 1.00 — nome IDÊNTICO com valor
# diferente (bloco B) e estratégico mis-bucketado (bloco C) pontuam o máximo
# e mesmo assim não podem ser cancelados. Nenhum corte superior a esse existe,
# então nenhum threshold separa por cima.
assert max_neg >= 1.0
assert max_neg >= min_pos
@pytest.mark.parametrize("threshold", (0.85, 0.90, 0.95, 0.97, 0.99))
def test_nenhum_threshold_de_similaridade_zera_falso_positivo(threshold: float):
"""Para TODO corte avaliado sobra pelo menos um falso positivo.
A similaridade sozinha jamais autoriza cancelamento: o que separa os casos é
o **valor ao centavo** e a **classe vinda da fatura** — nomes idênticos com
valores diferentes (bloco B) e estratégicos mis-bucketados (bloco C) pontuam
1.0 e mesmo assim não podem ser cancelados."""
rows = _shadow_rows()
false_positives = [c.case_id for c, s, safe in rows if not safe and s >= threshold]
assert false_positives, (
f"threshold {threshold} zerou os FP — revisar: o veredito "
"NOT_SAFE_FOR_AUTO_EXECUTION depende deste contraexemplo"
)
@pytest.mark.parametrize(
"case",
[pytest.param(c, id=c.case_id) for c in ALL_CASES if c.category == "prefixo"],
)
def test_guards_de_prefixo_valem_com_a_flag_ligada(case: Case, monkeypatch):
"""Os guards do prefixo de tokens precisam valer QUANDO ele roda.
Com a flag no default (off) o bloco E passaria por vacuidade — nada casa de
qualquer jeito, e uma regressão em ``_is_token_prefix`` (aceitar substring no
meio, aceitar prefixo invertido) não seria detectada por ninguém. Aqui a
segunda passada é ligada de propósito, então os guards são exercitados."""
from app.domain.contas import vas_variation
monkeypatch.setattr(vas_variation, "_VAS_QUALIFIED_NAME_MATCH", True)
items = _run(case) or []
resolved = [it.canonical_name for it in items]
# E06 é o FIT ME: com a flag ligada ele CASA (é o comportamento da passada).
esperado = ["FIT ME App Premium Mensal"] if case.case_id.startswith("E06") else []
assert resolved == esperado, f"{case.case_id}: {case.why}"
@pytest.mark.parametrize("case", [pytest.param(c, id=c.case_id) for c in ALL_CASES])
def test_explicacao_sem_data_nao_altera_nenhum_dos_50(case: Case):
"""Prova de propriedade: com os detalhes SEM ``period``, uma explicação
sintética não pode mudar nenhum dos 50 desfechos.
O desempate por data só age quando AMBOS os lados têm data legível. Como os
casos desta suíte não carregam `period`, o resultado tem de ser idêntico ao
run sem explicação — é o que prova que **ausência de data não autoriza
escolha por eliminação**."""
sintetica = (
"Houve a variação dos seguintes serviços de valor adicionado:\n"
f"* Cobrança {case.var_desc} no valor de R$ {case.var_value} no dia 01/01/26."
)
sem = _run(case) or []
com = (
varied_avulso_items(
invoice_variation=_variation(case.var_desc, case.var_value),
invoice_detail=_detail(case.invoice),
resolver=InvoiceResolver(),
by_charge=False,
explanation_text=sintetica,
)
or []
)
assert [it.canonical_name for it in sem] == [it.canonical_name for it in com], (
f"{case.case_id}: a explicação mudou o desfecho sem haver data no detalhe"
)