"""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" )