feat(z01): 마스터 145칸 한글 이름표 한 벌 — 파일 34 · 표 91 · 열 447 · 값 묶음 172
- `resources/data_master_labels/labels_2026-01-01.json` — 이름만 담고 값은 안 담음(고쳐도 계산 안 바뀜) - 갈래는 브레인 나눔표 그대로 — 로직 17 · 기초값 9 · 부산물 4 · 씨앗 1(`library_structure/masonry_wet`), 폴더 장부 `_manifest` 셋은 31 밖이라 부산물 · 강우 IDF 캐시 96 은 뺌 - 찾는 차례 `column_overrides["파일id/열key"]` → `columns["열key"]` → 영문 키 그대로 - `unit` 은 이름표에서만 정함(자료에서 지어내지 않음) · 내부 id·지문·원문 줄번호 14칸은 숨김 표시 - 뜻 못 밝힌 열 0 — 못 밝히면 이름을 비우고 `unknown` 에 사유를 적는 자리를 둠 - 새 시험 `test_master_labels_cover.py` 17건 — 마스터에 있는데 이름표에 없는 표·열, 반대로 묵은 이름표, 갈래 수, 세어 둔 수까지 대조(마스터가 늘면 빨강으로 알림) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
"""마스터 이름표(`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
|
||||
|
||||
|
||||
def _columns_of(node, shape: str) -> list[str]:
|
||||
records = node if shape == "list" else list(node.values())
|
||||
seen: list[str] = []
|
||||
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):
|
||||
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"]]
|
||||
Reference in New Issue
Block a user