Files
Aislo/resources/tester/test_master_labels_cover.py
T
eomsangdonandClaude Opus 5 da2238505c feat(z01): 이름표에 줄 이름 열 여덟과 「값·설명」 가름 172 칸
- 사전 모양 표 8 에 **줄 이름 열 `@key`** 를 둠(브레인 ①) — 표마다 제 이름:
  fx 「통화」 · oil 「유종」 · masonry_slope 「쌓기 방식」 · material_surcharge 「구실」 ·
  stone_kind·structure_unit 「출처 키」 · masonry_wet 「표 이름」·「칸 키」 · `is_row_key` 표시 붙임
- 값 묶음 172 를 **값 49 · 설명 123** 으로 가름(브레인 ②) — 잣대는
  「고치면 금액이 움직이나」 · 이름표 `policy.value_role_rule` 에 적음 · 갈래 뜻은 `value_roles` 에
- 값으로 선 것 — 한 값짜리 요율 여덟(산재·건강·요양·부가세·퇴직공제·임금채권·석면·안전관리비 기초액) ·
  요율마다의 밑수 · 시세 · 계수·원단위표(돌쌓기·거푸집·철근) · 잇는 값(노임 별칭·타설·배수관)
- 설명으로 숨는 것 — 방침·출처·비고·읽기 기록과 표 머리의 키·단위·기준일
- 시험 25건(7건 늘) — 줄 이름 열이 빠지거나 한 값짜리 요율이 설명으로 숨으면 빨강

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk
2026-09-15 19:17:28 +09:00

372 lines
14 KiB
Python

"""마스터 이름표(`resources/data_master_labels/labels_2026-01-01.json`) 덮임 시험.
이름표는 **값을 안 담고 이름만 담는다** — 그래서 마스터가 늘거나 열이 바뀌면
이름표가 조용히 뒤처진다. 이 시험이 그 어긋남을 잡는다.
- 마스터에 있는데 이름표에 없는 표·열 → 빨강
- 이름표에 있는데 마스터에 없는 표·열(묵은 이름표) → 빨강
- 갈래 나눔은 2026-09-15 브레인 갈래표(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1)와 대조
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.json"
#: 브레인 갈래 나눔표(2026-09-15). 폴더 장부(_manifest)는 그 31 밖이라 따로 센다.
BRAIN_KIND_COUNTS = {"logic": 17, "base_value": 9, "byproduct": 4, "seed": 1}
MANIFEST_PREFIX = "manifest_"
META_KEYS = {
"schema_version",
"dataset_id",
"effective_date",
"generated_at",
"publication_date",
"survey_month",
"pum_edition",
"dataset_version",
"source_master_file",
"source_dataset_version",
}
@pytest.fixture(scope="module")
def labels() -> dict:
return json.loads(LABELS_PATH.read_text(encoding="utf-8"))
def _is_table(node) -> str | None:
"""이름표 생성기와 **같은 잣대** — 화면이 표로 그릴 마디인가."""
if isinstance(node, list) and node and all(isinstance(r, dict) for r in node):
return "list"
if isinstance(node, dict):
values = list(node.values())
dicts = [v for v in values if isinstance(v, dict)]
if (
len(values) >= 2
and len(dicts) == len(values)
and not any(
isinstance(v.get("records"), list) or isinstance(v.get("rows"), list) for v in dicts
)
):
keysets = [frozenset(v.keys()) for v in dicts]
if len(set(keysets)) <= max(2, len(keysets) // 3 + 1):
return "map"
return None
def _has_table(node, depth: int = 0) -> bool:
if _is_table(node):
return True
if depth > 5 or not isinstance(node, dict):
return False
return any(_has_table(v, depth + 1) for v in node.values())
def _collect(node, path, tables, values, depth: int = 0) -> None:
shape = _is_table(node)
if shape:
tables.append(("/".join(path), shape, node))
return
if depth <= 5 and isinstance(node, dict) and any(_has_table(v) for v in node.values()):
for key, value in node.items():
_collect(value, path + [key], tables, values, depth + 1)
return
values.append("/".join(path))
def _scan(path: Path) -> tuple[list, list]:
doc = json.loads(path.read_text(encoding="utf-8"))
tables, values = [], []
for key, value in doc.items():
if key in META_KEYS:
continue
if key == "variables" and isinstance(value, dict) and not _is_table(value):
for sub_key, sub in value.items():
_collect(sub, ["variables", sub_key], tables, values)
else:
_collect(value, [key], tables, values)
return tables, values
#: 사전 모양 표의 **줄 이름 열** — API 가 이 이름으로 내보낸다(2026-09-15 브레인 ①).
ROW_KEY_COLUMN = "@key"
def _columns_of(node, shape: str) -> list[str]:
records = node if shape == "list" else list(node.values())
seen: list[str] = [ROW_KEY_COLUMN] if shape == "map" else []
for record in records:
if not isinstance(record, dict):
continue
for key in record:
if key not in seen:
seen.append(key)
return seen
def test_이름표_파일이_읽힌다(labels):
assert labels["dataset_id"] == "data_master_labels"
assert labels["files"], "이름표에 파일이 하나도 없다"
def test_이름표가_가리키는_파일이_다_있다(labels):
missing = [f["path"] for f in labels["files"] if not (ROOT / f["path"]).is_file()]
assert not missing, f"이름표가 없는 파일을 가리킨다: {missing}"
def test_갈래_나눔이_브레인_표와_같다(labels):
counted: dict[str, int] = {}
for entry in labels["files"]:
if entry["file_id"].startswith(MANIFEST_PREFIX):
continue
counted[entry["kind"]] = counted.get(entry["kind"], 0) + 1
assert counted == BRAIN_KIND_COUNTS
assert sum(counted.values()) == 31
def test_갈래_이름이_다_풀려_있다(labels):
known = set(labels["kinds"])
used = {f["kind"] for f in labels["files"]}
assert used <= known, f"뜻을 안 적은 갈래: {sorted(used - known)}"
def test_폴더_지문은_부산물이_아니다(labels):
"""`_manifest` 는 계산이 남긴 기록이 아니라 **어느 판으로 셈했는지 못박는 표**다 —
「정본 아님」 딱지가 붙으면 안 된다(2026-09-15 브레인 ②)."""
manifests = [f for f in labels["files"] if f["file_id"].startswith(MANIFEST_PREFIX)]
assert manifests, "폴더 지문이 이름표에 하나도 없다"
for entry in manifests:
assert entry["kind"] == "fingerprint", f"{entry['file_id']} 갈래가 {entry['kind']} 다"
assert "정본" not in labels["kinds"]["fingerprint"]["summary"]
def test_마스터의_표가_이름표에_다_있다(labels):
missing = []
for entry in labels["files"]:
tables, _ = _scan(ROOT / entry["path"])
labelled = {t["key"] for t in entry["tables"]}
for key, _shape, _node in tables:
if key not in labelled:
missing.append(f"{entry['file_id']}::{key}")
assert not missing, f"이름표에 없는 표: {missing}"
def test_이름표의_표가_마스터에_다_있다(labels):
stale = []
for entry in labels["files"]:
tables, _ = _scan(ROOT / entry["path"])
actual = {key for key, _s, _n in tables}
for table in entry["tables"]:
if table["key"] not in actual:
stale.append(f"{entry['file_id']}::{table['key']}")
assert not stale, f"마스터에 없는 묵은 이름표: {stale}"
def test_마스터의_값_묶음이_이름표에_다_있다(labels):
missing = []
for entry in labels["files"]:
_tables, values = _scan(ROOT / entry["path"])
labelled = {v["key"] for v in entry["value_groups"]}
for key in values:
if key not in labelled:
missing.append(f"{entry['file_id']}::{key}")
assert not missing, f"이름표에 없는 값 묶음: {missing}"
def test_표_이름이_비지_않았다(labels):
blank = [
f"{entry['file_id']}::{table['key']}"
for entry in labels["files"]
for table in entry["tables"]
if not table["name_ko"].strip()
]
assert not blank, f"한글 이름이 빈 표: {blank}"
def test_값_묶음_이름이_비지_않았다(labels):
blank = [
f"{entry['file_id']}::{group['key']}"
for entry in labels["files"]
for group in entry["value_groups"]
if not group["name_ko"].strip()
]
assert not blank, f"한글 이름이 빈 값 묶음: {blank}"
def test_마스터의_열이_이름표에_다_있다(labels):
missing = []
for entry in labels["files"]:
tables, _ = _scan(ROOT / entry["path"])
by_key = {t["key"]: t for t in entry["tables"]}
for key, shape, node in tables:
table = by_key.get(key)
if table is None:
continue
labelled = {c["key"] for c in table["columns"]}
for column in _columns_of(node, shape):
if column not in labelled:
missing.append(f"{entry['file_id']}::{key}/{column}")
assert not missing, f"이름표에 없는 열: {missing}"
def test_이름표의_열이_마스터에_다_있다(labels):
stale = []
for entry in labels["files"]:
tables, _ = _scan(ROOT / entry["path"])
actual = {key: _columns_of(node, shape) for key, shape, node in tables}
for table in entry["tables"]:
known = set(actual.get(table["key"], ()))
for column in table["columns"]:
if column["key"] not in known:
stale.append(f"{entry['file_id']}::{table['key']}/{column['key']}")
assert not stale, f"마스터에 없는 묵은 열 이름표: {stale}"
def test_열_이름은_붙었거나_사유가_있다(labels):
bad = []
for entry in labels["files"]:
for table in entry["tables"]:
for column in table["columns"]:
if column["name_ko"].strip():
continue
if not column.get("unknown_reason", "").strip():
bad.append(f"{entry['file_id']}::{table['key']}/{column['key']}")
assert not bad, f"이름도 사유도 없는 열: {bad}"
def test_이름_없는_열은_unknown_에도_적혀_있다(labels):
listed = {u["where"] for u in labels["unknown"]}
for entry in labels["files"]:
for table in entry["tables"]:
for column in table["columns"]:
if column["name_ko"].strip():
continue
where = f"{entry['file_id']}::{table['key']}/{column['key']}"
assert where in listed, f"unknown 에 안 적힌 빈 열: {where}"
def test_찾는_차례가_적혀_있다(labels):
order = labels["lookup_order"]["column"]
assert order[0].startswith("column_overrides")
assert order[1].startswith("columns")
def test_덮어쓰기_이름표가_다_쓰인다(labels):
"""`column_overrides` 는 「파일id/열key」 꼴이고 그 파일에 실제로 그 열이 있어야 한다."""
by_file = {}
for entry in labels["files"]:
cols = set()
for table in entry["tables"]:
cols |= {c["key"] for c in table["columns"]}
by_file[entry["file_id"]] = cols
dangling = []
for key in labels["column_overrides"]:
file_id, _, column = key.partition("/")
if column not in by_file.get(file_id, set()):
dangling.append(key)
assert not dangling, f"쓰이지 않는 열 덮어쓰기: {dangling}"
def test_세어_둔_수가_실제와_같다(labels):
counts = labels["counts"]
assert counts["files"] == len(labels["files"])
assert counts["tables"] == sum(len(f["tables"]) for f in labels["files"])
assert counts["columns"] == sum(len(t["columns"]) for f in labels["files"] for t in f["tables"])
assert counts["value_groups"] == sum(len(f["value_groups"]) for f in labels["files"])
assert counts["unknown"] == len(labels["unknown"])
def test_강우_IDF_캐시는_안_담았다(labels):
assert not [f for f in labels["files"] if "rainfall" in f["path"]]
def test_사전_모양_표에는_줄_이름_열이_있다(labels):
"""`fx`·`oil` 처럼 사전 모양인 표는 **줄 이름(키) 자체가 한 열**이다.
그 열이 없으면 화면에 영문 `@key` 로 뜬다(2026-09-15 브레인 ①)."""
bad = []
for entry in labels["files"]:
for table in entry["tables"]:
if table["shape"] != "map":
continue
first = table["columns"][0] if table["columns"] else None
if first is None or first["key"] != ROW_KEY_COLUMN:
bad.append(f"{entry['file_id']}::{table['key']} — 줄 이름 열 없음")
elif not first["name_ko"].strip():
bad.append(f"{entry['file_id']}::{table['key']} — 줄 이름 열에 한글 이름 없음")
elif not first.get("is_row_key"):
bad.append(f"{entry['file_id']}::{table['key']} — is_row_key 표시 없음")
assert not bad, f"줄 이름 열 문제: {bad}"
def test_줄_이름_열은_사전_모양_표에만_있다(labels):
stray = [
f"{entry['file_id']}::{table['key']}"
for entry in labels["files"]
for table in entry["tables"]
if table["shape"] != "map" and any(c["key"] == ROW_KEY_COLUMN for c in table["columns"])
]
assert not stray, f"사전 모양이 아닌데 줄 이름 열이 있다: {stray}"
def test_줄_이름_열의_이름이_표마다_제_것이다(labels):
"""뜻이 표마다 다르므로 한 이름으로 뭉치면 안 된다 — 같은 이름이 겹치면 살펴볼 것."""
names = [
column["name_ko"]
for entry in labels["files"]
for table in entry["tables"]
for column in table["columns"]
if column["key"] == ROW_KEY_COLUMN
]
assert names, "줄 이름 열이 하나도 없다"
# 「출처 키」만 두 표가 같은 뜻으로 쓴다(출처 사전 둘). 그 밖은 겹치지 않는다.
duplicated = {n for n in names if names.count(n) > 1}
assert duplicated <= {"출처 키"}, f"줄 이름이 겹친다: {sorted(duplicated)}"
def test_값_묶음마다_값이냐_설명이냐가_적혀_있다(labels):
known = set(labels["value_roles"])
bad = []
for entry in labels["files"]:
for group in entry["value_groups"]:
if group.get("role") not in known:
bad.append(f"{entry['file_id']}::{group['key']} — {group.get('role')!r}")
assert not bad, f"값·설명이 안 갈린 값 묶음: {bad}"
def test_값_갈래_잣대가_이름표에_적혀_있다(labels):
rule = labels["policy"]["value_role_rule"]
assert "금액이 움직이나" in rule
assert labels["policy"]["row_key_column"]
def test_한_값짜리_요율은_값으로_선다(labels):
"""산재 3.56 % 처럼 원가에 그대로 드는 요율이 설명으로 숨으면 트리에서 사라진다."""
rates = next(f for f in labels["files"] if f["file_id"] == "rates")
by_key = {g["key"]: g["role"] for g in rates["value_groups"]}
for key in (
"variables/rate_sanjae",
"variables/rate_health",
"variables/rate_care",
"variables/rate_vat",
"variables/rate_retirement_mutual_aid",
"variables/rate_wage_claim_contribution",
"variables/rate_asbestos_contribution",
):
assert by_key.get(key) == "value", f"{key} 가 값으로 안 섰다"
assert by_key.get("processing_rules") == "note"
def test_세어_둔_갈래_수가_실제와_같다(labels):
counted: dict[str, int] = {}
for entry in labels["files"]:
for group in entry["value_groups"]:
counted[group["role"]] = counted.get(group["role"], 0) + 1
assert counted == labels["counts"]["value_groups_by_role"]