Projeto do Agent Contas ORACLE
This commit is contained in:
692
tests/migration/test_original_vas_variation_adversarial.py
Normal file
692
tests/migration/test_original_vas_variation_adversarial.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user