Files
agent_contas/tests/migration/test_original_vas_variation_adversarial.py

693 lines
26 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.
"""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"
)