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