From b7e05a932cc85abc20c8999f3f361dc965a5dce5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:03:52 +0000 Subject: [PATCH 1/3] test: add reproducible domain table generator Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/domain-tables.yml | 25 ++ resources/domains/README.md | 23 ++ tools/generate_domain_tables.py | 348 +++++++++++++++++++++ tools/tests/test_generate_domain_tables.py | 152 +++++++++ 4 files changed, 548 insertions(+) create mode 100644 .github/workflows/domain-tables.yml create mode 100644 tools/generate_domain_tables.py create mode 100644 tools/tests/test_generate_domain_tables.py diff --git a/.github/workflows/domain-tables.yml b/.github/workflows/domain-tables.yml new file mode 100644 index 0000000..16cc581 --- /dev/null +++ b/.github/workflows/domain-tables.yml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Domain Table Generator + +on: + pull_request: + paths: + - "tools/generate_domain_tables.py" + - "tools/tests/test_generate_domain_tables.py" + - "resources/domains/**" + - ".github/workflows/domain-tables.yml" + +permissions: + contents: read + +jobs: + deterministic-generator: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test deterministic XLSX normalization + run: python3 -m unittest tools/tests/test_generate_domain_tables.py diff --git a/resources/domains/README.md b/resources/domains/README.md index cd909be..c52070f 100644 --- a/resources/domains/README.md +++ b/resources/domains/README.md @@ -38,3 +38,26 @@ When an official annex changes: 4. update the version constant in `OfficialDomainCatalog`; 5. update cardinality and representative-code tests; 6. review downstream mapping changes explicitly instead of silently coercing old values. + + +## Reproducible generation + +The snapshots are generated directly from the three official XLSX annexes with only the Python +standard library: + +```bash +python3 tools/generate_domain_tables.py \ + --annex-a /path/to/ANEXO_A.xlsx \ + --annex-b /path/to/ANEXO_B.xlsx \ + --annex-c /path/to/ANEXO_C.xlsx \ + --output resources/domains +``` + +Use `--check` to compare regenerated output with the committed snapshots. The command validates +the expected official cardinalities (5,571 municipalities/general localities, 250 countries, +338 national service codes, 918 nine-digit NBS codes and 26 operation indicators) before writing +or accepting output. + +The generator reads XLSX as ZIP/XML, honors cell references and vertically merged cells, derives +UF only from the stable IBGE prefix map, and rejects an unexpected cardinality. A network-free CI +fixture exercises the same parser and proves deterministic output. diff --git a/tools/generate_domain_tables.py b/tools/generate_domain_tables.py new file mode 100644 index 0000000..b0a9813 --- /dev/null +++ b/tools/generate_domain_tables.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Generate normalized NFS-e domain TSV snapshots directly from official XLSX annexes.""" + +from __future__ import annotations + +import argparse +import re +import sys +import tempfile +import unicodedata +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" + +UF_BY_IBGE_PREFIX = { + "11": "RO", "12": "AC", "13": "AM", "14": "RR", "15": "PA", "16": "AP", "17": "TO", + "21": "MA", "22": "PI", "23": "CE", "24": "RN", "25": "PB", "26": "PE", "27": "AL", + "28": "SE", "29": "BA", "31": "MG", "32": "ES", "33": "RJ", "35": "SP", "41": "PR", + "42": "SC", "43": "RS", "50": "MS", "51": "MT", "52": "GO", "53": "DF", +} + +EXPECTED_COUNTS = { + "municipios-ibge-v1.00.tsv": 5571, + "paises-iso2-v1.00.tsv": 250, + "servicos-nacionais-v1.01.tsv": 338, + "nbs-v2.0.tsv": 918, + "indicadores-operacao-ibscbs-v1.01.tsv": 26, +} + +HEADERS = { + "municipios-ibge-v1.00.tsv": [ + "# Municípios IBGE — ANEXO_A-MUNICIPIO_IBGE-PAISES_ISO2-v1.00-SNNFSe-20251210", + "# Formato: código IBGEsigla UFnome do município. UF vazia = localidade geral.", + "# A sigla é derivada dos 2 primeiros dígitos do código (padrão IBGE): o anexo só a", + "# preenche em 450 das 5570 linhas. A derivação foi conferida contra essas 450, contra", + "# o enum TSUF do XSD e contra o conjunto de prefixos presentes no anexo.", + ], + "paises-iso2-v1.00.tsv": [ + "# Países ISO 3166-1 alpha-2 — ANEXO_A-MUNICIPIO_IBGE-PAISES_ISO2-v1.00-SNNFSe-20251210", + "# Formato: código ISO2nome", + ], + "servicos-nacionais-v1.01.tsv": [ + "# Lista de Serviços Nacional — ANEXO_B-NBS2-LISTA_SERVICO_NACIONAL-SNNFSe-v1.01-20260122", + "# Gerado de doc/anexos/, aba LISTA.SERV.NAC. Formato: cTribNacdescrição", + ], + "nbs-v2.0.tsv": [ + "# Nomenclatura Brasileira de Serviços 2.0 — ANEXO_B-NBS2-LISTA_SERVICO_NACIONAL-SNNFSe-v1.01-20260122", + "# Aba LISTA.NBS_v2.0. Formato: cNBS (9 dígitos, sem pontos)descrição.", + "# Das 1210 linhas do anexo, 292 são níveis de hierarquia (5, 6, 7 e 8 dígitos) e não", + "# são valores válidos de cNBS, que o XSD tipa como [0-9]{9}.", + "# 999999999 vem sem descrição no anexo; é o código genérico, e é o que a NFS-e real", + "# consultada em produção usa. Mantido com descrição vazia — não se inventa texto.", + ], + "indicadores-operacao-ibscbs-v1.01.tsv": [ + "# Indicadores de operação IBS/CBS — ANEXO_C-INDOP_IBSCBS-SNNFSe-v1.01-20260122", + "# Formato: cIndOpcaracterística do fornecimentolocal a identificar no DF-e", + ], +} + + +def normalized_sheet_name(value: str) -> str: + decomposed = unicodedata.normalize("NFKD", value) + return "".join(ch for ch in decomposed if not unicodedata.combining(ch)).upper().strip() + + +def column(ref: str) -> str: + match = re.match(r"([A-Z]+)", ref) + return match.group(1) if match else "" + + +def clean(value: str) -> str: + return re.sub(r"\s+", " ", value.replace("\t", " ")).strip() + + +def digits(value: str) -> str: + return re.sub(r"\D+", "", value) + + +class Workbook: + def __init__(self, path: Path): + self.path = path + self.zip = zipfile.ZipFile(path) + self.shared_strings = self._read_shared_strings() + self.sheet_targets = self._read_sheet_targets() + + def close(self) -> None: + self.zip.close() + + def __enter__(self) -> "Workbook": + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + def _read_shared_strings(self) -> list[str]: + try: + raw = self.zip.read("xl/sharedStrings.xml") + except KeyError: + return [] + root = ET.fromstring(raw) + values = [] + for item in root.findall(f"{{{MAIN_NS}}}si"): + values.append("".join(node.text or "" for node in item.iter(f"{{{MAIN_NS}}}t"))) + return values + + def _read_sheet_targets(self) -> dict[str, str]: + workbook = ET.fromstring(self.zip.read("xl/workbook.xml")) + rels = ET.fromstring(self.zip.read("xl/_rels/workbook.xml.rels")) + targets = { + rel.attrib["Id"]: rel.attrib["Target"] + for rel in rels.findall(f"{{{PKG_REL_NS}}}Relationship") + } + sheets: dict[str, str] = {} + for sheet in workbook.findall(f".//{{{MAIN_NS}}}sheet"): + rel_id = sheet.attrib[f"{{{REL_NS}}}id"] + target = targets[rel_id] + if not target.startswith("/"): + target = "xl/" + target.lstrip("/") + else: + target = target.lstrip("/") + sheets[normalized_sheet_name(sheet.attrib["name"])] = target + return sheets + + def rows(self, sheet_name: str) -> list[dict[str, str]]: + key = normalized_sheet_name(sheet_name) + if key not in self.sheet_targets: + raise ValueError(f"worksheet not found: {sheet_name}") + root = ET.fromstring(self.zip.read(self.sheet_targets[key])) + rows: list[dict[str, str]] = [] + by_number: dict[int, dict[str, str]] = {} + for row in root.findall(f".//{{{MAIN_NS}}}row"): + number = int(row.attrib.get("r", len(rows) + 1)) + values: dict[str, str] = {} + for cell in row.findall(f"{{{MAIN_NS}}}c"): + ref = cell.attrib.get("r", "") + col = column(ref) + cell_type = cell.attrib.get("t", "") + value = "" + if cell_type == "inlineStr": + value = "".join(node.text or "" for node in cell.iter(f"{{{MAIN_NS}}}t")) + else: + node = cell.find(f"{{{MAIN_NS}}}v") + raw = node.text if node is not None and node.text is not None else "" + if cell_type == "s" and raw != "": + value = self.shared_strings[int(raw)] + else: + value = raw + values[col] = clean(value) + rows.append(values) + by_number[number] = values + + # Preserve merged-cell semantics used by the official annexes. + for merged in root.findall(f".//{{{MAIN_NS}}}mergeCell"): + ref = merged.attrib.get("ref", "") + match = re.fullmatch(r"([A-Z]+)(\d+):([A-Z]+)(\d+)", ref) + if not match: + continue + start_col, start_row, end_col, end_row = match.groups() + if start_col != end_col: + continue + start = int(start_row) + end = int(end_row) + source = by_number.get(start, {}).get(start_col, "") + if source == "": + continue + for number in range(start, end + 1): + by_number.setdefault(number, {}).setdefault(start_col, source) + + return rows + + +def longest_text(values: list[str], excluded: set[str]) -> str: + candidates = [ + clean(value) for value in values + if clean(value) not in excluded + and clean(value) != "" + and not re.fullmatch(r"[\d.\-/]+", clean(value)) + ] + return max(candidates, key=len, default="") + + +def extract_municipalities(book: Workbook) -> list[list[str]]: + rows: list[list[str]] = [] + seen: set[str] = set() + for row in book.rows("TAB.MUN_IBGE"): + code = digits(row.get("D", "")) + name = clean(row.get("C", "")) + if len(code) != 7 or name == "" or code in seen: + continue + prefix = code[:2] + if prefix not in UF_BY_IBGE_PREFIX: + raise ValueError(f"unknown IBGE state prefix: {prefix}") + rows.append([code, UF_BY_IBGE_PREFIX[prefix], name]) + seen.add(code) + + # The annex publishes a separate general-locality sheet (e.g. maritime waters). + for row in book.rows("TAB.LOC.GERAL"): + values = list(row.values()) + for value in values: + code = digits(value) + if len(code) != 7 or code in seen: + continue + name = longest_text(values, {value}) + if name: + rows.append([code, "", name]) + seen.add(code) + return rows + + +def extract_countries(book: Workbook) -> list[list[str]]: + rows: list[list[str]] = [] + seen: set[str] = set() + for row in book.rows("TAB.PAÍS_ISO2"): + values = [clean(value) for value in row.values()] + code = next((value for value in values if re.fullmatch(r"[A-Z]{2}", value)), "") + if code == "" or code in seen: + continue + name = longest_text(values, {code}) + if name: + rows.append([code, name]) + seen.add(code) + return rows + + +def extract_code_description(book: Workbook, sheet: str, length: int) -> list[list[str]]: + rows: list[list[str]] = [] + seen: set[str] = set() + for row in book.rows(sheet): + values = [clean(value) for value in row.values()] + code_value = next((value for value in values if len(digits(value)) == length), "") + if code_value == "": + continue + code = digits(code_value) + if code in seen: + continue + description = longest_text(values, {code_value, code}) + if description == "" and code != "999999999": + continue + rows.append([code, description]) + seen.add(code) + return rows + + +def extract_indicators(book: Workbook) -> list[list[str]]: + rows: list[list[str]] = [] + seen: set[str] = set() + for sheet in book.sheet_targets: + for row in book.rows(sheet): + values = [clean(value) for value in row.values()] + code_value = next( + (value for value in values if re.fullmatch(r"\d{6}", digits(value))), + "", + ) + if code_value == "": + continue + code = digits(code_value) + if code in seen: + continue + texts = [ + value for value in values + if value not in {code_value, code} + and value != "" + and not re.fullmatch(r"[\d.\-/]+", value) + ] + if len(texts) < 2: + continue + # Characteristic is normally the longer prose field; location is the other. + ranked = sorted(texts, key=len, reverse=True) + characteristic = ranked[0] + location = ranked[1] + rows.append([code, characteristic, location]) + seen.add(code) + return rows + + +def render(name: str, rows: list[list[str]]) -> str: + body = ["\t".join(row) for row in rows] + return "\n".join(HEADERS[name] + body) + "\n" + + +def generate(annex_a: Path, annex_b: Path, annex_c: Path) -> dict[str, str]: + with Workbook(annex_a) as book_a, Workbook(annex_b) as book_b, Workbook(annex_c) as book_c: + outputs = { + "municipios-ibge-v1.00.tsv": render("municipios-ibge-v1.00.tsv", extract_municipalities(book_a)), + "paises-iso2-v1.00.tsv": render("paises-iso2-v1.00.tsv", extract_countries(book_a)), + "servicos-nacionais-v1.01.tsv": render( + "servicos-nacionais-v1.01.tsv", + extract_code_description(book_b, "LISTA.SERV.NAC.", 6), + ), + "nbs-v2.0.tsv": render( + "nbs-v2.0.tsv", + extract_code_description(book_b, "LISTA.NBS_v2.0", 9), + ), + "indicadores-operacao-ibscbs-v1.01.tsv": render( + "indicadores-operacao-ibscbs-v1.01.tsv", + extract_indicators(book_c), + ), + } + return outputs + + +def validate_counts(outputs: dict[str, str]) -> None: + for name, expected in EXPECTED_COUNTS.items(): + actual = sum(1 for line in outputs[name].splitlines() if line and not line.startswith("#")) + if actual != expected: + raise ValueError(f"{name}: expected {expected} data rows, generated {actual}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--annex-a", required=True, type=Path) + parser.add_argument("--annex-b", required=True, type=Path) + parser.add_argument("--annex-c", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--check", action="store_true", help="fail if generated output differs from existing files") + parser.add_argument("--skip-cardinality-check", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + + outputs = generate(args.annex_a, args.annex_b, args.annex_c) + if not args.skip_cardinality_check: + validate_counts(outputs) + + args.output.mkdir(parents=True, exist_ok=True) + failures = [] + for name, content in outputs.items(): + path = args.output / name + if args.check: + current = path.read_text(encoding="utf-8") if path.exists() else "" + if current != content: + failures.append(name) + else: + path.write_text(content, encoding="utf-8") + + if failures: + print("Domain snapshots differ: " + ", ".join(failures), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_generate_domain_tables.py b/tools/tests/test_generate_domain_tables.py new file mode 100644 index 0000000..d92ef4d --- /dev/null +++ b/tools/tests/test_generate_domain_tables.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +import zipfile +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "generate_domain_tables.py" +SPEC = importlib.util.spec_from_file_location("domain_generator", SCRIPT) +assert SPEC and SPEC.loader +GEN = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(GEN) + + +def col(index: int) -> str: + out = "" + while index: + index, rem = divmod(index - 1, 26) + out = chr(65 + rem) + out + return out + + +def workbook(path: Path, sheets: dict[str, list[list[str]]], merges: dict[str, list[str]] | None = None) -> None: + merges = merges or {} + shared: list[str] = [] + index: dict[str, int] = {} + + def shared_id(value: str) -> int: + if value not in index: + index[value] = len(shared) + shared.append(value) + return index[value] + + workbook_xml = [ + '', + '', + ] + rels = [ + '', + '', + ] + sheet_xml: list[tuple[str, str]] = [] + + for idx, (name, rows) in enumerate(sheets.items(), start=1): + workbook_xml.append(f'') + rels.append( + f'' + ) + parts = [ + '', + '', + ] + for row_number, row in enumerate(rows, start=1): + parts.append(f'') + for column_number, value in enumerate(row, start=1): + if value == "": + continue + ref = f"{col(column_number)}{row_number}" + sid = shared_id(value) + parts.append(f'{sid}') + parts.append('') + parts.append('') + if merges.get(name): + parts.append(f'') + for ref in merges[name]: + parts.append(f'') + parts.append('') + parts.append('') + sheet_xml.append((f"xl/worksheets/sheet{idx}.xml", "".join(parts))) + + workbook_xml.append("") + rels.append("") + shared_xml = ( + '' + '' + + "".join(f"{value}" for value in shared) + + "" + ) + + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("xl/workbook.xml", "".join(workbook_xml)) + archive.writestr("xl/_rels/workbook.xml.rels", "".join(rels)) + archive.writestr("xl/sharedStrings.xml", shared_xml) + for target, xml in sheet_xml: + archive.writestr(target, xml) + + +class DomainGeneratorTest(unittest.TestCase): + def test_generates_deterministic_normalized_snapshots(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + a = root / "a.xlsx" + b = root / "b.xlsx" + c = root / "c.xlsx" + + workbook(a, { + "TAB.MUN_IBGE": [ + ["UF", "", "Município", "Código"], + ["Rio de Janeiro", "", "Rio de Janeiro", "3304557"], + ], + "TAB.LOC.GERAL": [["Código", "Descrição"], ["0000000", "ÁGUAS MARÍTIMAS"]], + "TAB.PAÍS_ISO2": [["Código", "País"], ["BR", "Brasil"], ["US", "Estados Unidos"]], + }) + workbook(b, { + "LISTA.SERV.NAC.": [ + ["Código", "Descrição"], + ["01.01.01", "Análise e desenvolvimento de sistemas."], + ["01.01", "Cabeçalho que não é código utilizável"], + ], + "LISTA.NBS_v2.0": [ + ["Código", "Descrição"], + ["1.0101.11.00", "Serviço NBS válido"], + ["1.0101.11", "Nível hierárquico"], + ], + }) + workbook(c, { + "INDOP": [ + ["Código", "Característica", "Local"], + ["020101", "Execução sobre bem imóvel", "Localidade do imóvel (1)"], + ], + }) + + first = GEN.generate(a, b, c) + second = GEN.generate(a, b, c) + self.assertEqual(first, second) + self.assertIn("3304557\tRJ\tRio de Janeiro", first["municipios-ibge-v1.00.tsv"]) + self.assertIn("0000000\t\tÁGUAS MARÍTIMAS", first["municipios-ibge-v1.00.tsv"]) + self.assertIn("BR\tBrasil", first["paises-iso2-v1.00.tsv"]) + self.assertIn("010101\tAnálise e desenvolvimento de sistemas.", first["servicos-nacionais-v1.01.tsv"]) + self.assertNotIn("0101\t", first["servicos-nacionais-v1.01.tsv"]) + self.assertIn("10101100", first["nbs-v2.0.tsv"]) is False + self.assertIn("020101\tExecução sobre bem imóvel\tLocalidade do imóvel (1)", first["indicadores-operacao-ibscbs-v1.01.tsv"]) + + def test_cardinality_guard_rejects_partial_annexes(self) -> None: + partial = { + name: "\n".join(GEN.HEADERS[name] + ["000000\tfixture"]) + "\n" + for name in GEN.EXPECTED_COUNTS + } + with self.assertRaises(ValueError): + GEN.validate_counts(partial) + + +if __name__ == "__main__": + unittest.main() From 76fb21a9a7baf8ca15db40e7d7768b8e63e63b3c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:07:54 +0000 Subject: [PATCH 2/3] test: fix NBS hierarchy assertion Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tools/tests/test_generate_domain_tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/tests/test_generate_domain_tables.py b/tools/tests/test_generate_domain_tables.py index d92ef4d..cb93e24 100644 --- a/tools/tests/test_generate_domain_tables.py +++ b/tools/tests/test_generate_domain_tables.py @@ -136,7 +136,7 @@ def test_generates_deterministic_normalized_snapshots(self) -> None: self.assertIn("BR\tBrasil", first["paises-iso2-v1.00.tsv"]) self.assertIn("010101\tAnálise e desenvolvimento de sistemas.", first["servicos-nacionais-v1.01.tsv"]) self.assertNotIn("0101\t", first["servicos-nacionais-v1.01.tsv"]) - self.assertIn("10101100", first["nbs-v2.0.tsv"]) is False + self.assertNotIn("10101100\\t", first["nbs-v2.0.tsv"]) self.assertIn("020101\tExecução sobre bem imóvel\tLocalidade do imóvel (1)", first["indicadores-operacao-ibscbs-v1.01.tsv"]) def test_cardinality_guard_rejects_partial_annexes(self) -> None: From 3cee267422fe89026ea0e5dc35aa1877b6bc613b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:10:47 +0000 Subject: [PATCH 3/3] test: compare generated domain codes exactly Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tools/tests/test_generate_domain_tables.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tools/tests/test_generate_domain_tables.py b/tools/tests/test_generate_domain_tables.py index cb93e24..b53babd 100644 --- a/tools/tests/test_generate_domain_tables.py +++ b/tools/tests/test_generate_domain_tables.py @@ -135,8 +135,18 @@ def test_generates_deterministic_normalized_snapshots(self) -> None: self.assertIn("0000000\t\tÁGUAS MARÍTIMAS", first["municipios-ibge-v1.00.tsv"]) self.assertIn("BR\tBrasil", first["paises-iso2-v1.00.tsv"]) self.assertIn("010101\tAnálise e desenvolvimento de sistemas.", first["servicos-nacionais-v1.01.tsv"]) - self.assertNotIn("0101\t", first["servicos-nacionais-v1.01.tsv"]) - self.assertNotIn("10101100\\t", first["nbs-v2.0.tsv"]) + service_codes = { + line.split("\\t", 1)[0] + for line in first["servicos-nacionais-v1.01.tsv"].splitlines() + if line and not line.startswith("#") + } + nbs_codes = { + line.split("\\t", 1)[0] + for line in first["nbs-v2.0.tsv"].splitlines() + if line and not line.startswith("#") + } + self.assertNotIn("0101", service_codes) + self.assertNotIn("10101100", nbs_codes) self.assertIn("020101\tExecução sobre bem imóvel\tLocalidade do imóvel (1)", first["indicadores-operacao-ibscbs-v1.01.tsv"]) def test_cardinality_guard_rejects_partial_annexes(self) -> None: