"""마스터 이름표(`resources/data_master_labels/labels_2026-01-01.json`) 덮임 시험. 이름표는 **값을 안 담고 이름만 담는다** — 그래서 마스터가 늘거나 열이 바뀌면 이름표가 조용히 뒤처진다. 이 시험이 그 어긋남을 잡는다. - 마스터에 있는데 이름표에 없는 표·열 → 빨강 - 이름표에 있는데 마스터에 없는 표·열(묵은 이름표) → 빨강 - 갈래 나눔은 2026-09-15 브레인 갈래표(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1)와 대조 """ from __future__ import annotations import json import re 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 밖이라 따로 센다. #: 2026-09-16 사용자 판정 — `structure_unit_observed`(울진소광 한 현장 관찰값)와 #: `revetment_sabang`(교본값에 설명 글 섞임)은 기초데이터가 아니라 **참고 사례**다. BRAIN_KIND_COUNTS = { "logic": 19, "base_value": 9, "byproduct": 6, "reference": 2, "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 # 2026-09-17 31 → 37 — 공종 축 산출 일곱을 이름표에 올림(건설 마스터·미판정·밑수 · 열쇠 장부 둘 · # 구실 잣대 · 건설 폴더 지문). 지문은 manifest 라 이 셈에서 빠진다. assert sum(counted.values()) == 37 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): """⚠ 빈칸이 아니라 **영문 키가 그대로 이름 자리에 앉은** 것을 잡는다(2026-09-17 데스크탑 서브). 새 파일을 이름표에 올릴 때 이름을 못 지으면 키를 그대로 베끼기 쉬운데, 그러면 화면에 `general_provision_roots` 같은 영문이 뜬다. 한글이 한 글자도 없으면 이름을 안 지은 것이다. """ hangul = re.compile(r"[가-힣]") bad = [] for entry in labels["files"]: for table in entry["tables"]: for column in table["columns"]: if not hangul.search(column["name_ko"]): bad.append(f"{entry['file_id']}::{table['key']}/{column['key']}") if not hangul.search(table["name_ko"]): bad.append(f"{entry['file_id']}::{table['key']}") for group in entry["value_groups"]: if not hangul.search(group["name_ko"]): bad.append(f"{entry['file_id']}::{group['key']}") for merged in labels["merged_tables"]: for column in merged["columns"]: if not hangul.search(column["name_ko"]): bad.append(f"{merged['key']}/{column['key']}") assert not bad, f"영문으로 뜨는 칸: {bad}" 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_값_묶음마다_kind_가_낱말로_적혀_있다(labels): known = set(labels["value_kinds"]) bad = [] for entry in labels["files"]: for group in entry["value_groups"]: if group.get("kind") 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_kind_rule"] assert "금액이 움직이나" in rule assert labels["policy"]["row_key_column"] node = labels["synthetic_tables"]["@values"] assert node["name_ko"] == "낱값" assert node["summary"] == "표가 아닌 한 값들" assert [c["key"] for c in node["columns"]] == ["@name", "@value", "@path", "@undecided"] assert [c["name_ko"] for c in node["columns"]] == ["항목", "값", "원문 자리", "미판정"] assert node["columns"][2]["visible"] is False def test_값_갈래는_참거짓이_아니라_낱말이다(labels): """수식 같은 갈래가 늘면 낱말만 더하면 된다 — 참·거짓이면 그때 뜻이 뒤집힌다.""" for entry in labels["files"]: for group in entry["value_groups"]: assert isinstance(group["kind"], str), f"{entry['file_id']}::{group['key']}" assert set(labels["value_kinds"]) == {"value", "doc"} def test_한_값짜리_요율은_값으로_선다(labels): """산재 3.56 % 처럼 원가에 그대로 드는 요율이 설명으로 숨으면 트리에서 사라진다.""" rates = next(f for f in labels["files"] if f["file_id"] == "rates") by_key = {g["key"]: g["kind"] 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") == "doc" def test_세어_둔_값_갈래_수가_실제와_같다(labels): counted: dict[str, int] = {} for entry in labels["files"]: for group in entry["value_groups"]: counted[group["kind"]] = counted.get(group["kind"], 0) + 1 assert counted == labels["counts"]["value_groups_by_kind"] def test_곁값은_값_쪽에_선다(labels): """값 단위·기준일·걸치는 범위는 **없으면 값의 뜻이 안 선다** — 설명으로 숨기면 「유가 1,858」만 떠서 리터인지 드럼인지, 언제 값인지 모른다(2026-09-15 브레인 ②).""" want = { "coef": ("variables/surcharge_labor/unit", "variables/surcharge_mat/unit"), "mach_base": ( "variables/mach_fuel_rate/unit", "variables/mach_loss_coef/unit", "variables/mach_price/unit", "variables/mach_rock_adj/unit", ), "oil_regional": ( "variables/oil_diesel/unit", "variables/oil_gasoline/unit", "variables/oil_diesel/date", "variables/oil_gasoline/date", "variables/oil_diesel/scope", "variables/oil_gasoline/scope", ), "revetment_sabang": ("scope",), "timber_structure_class": ("basis_unit",), "rates": ( "variables/rate_equipment_payment_guarantee/base_note", "variables/rate_environment/forest_road_selection_status", "variables/rate_performance_guarantee_fee/typical_forest_road_applicability", ), } by_file = { f["file_id"]: {g["key"]: g["kind"] for g in f["value_groups"]} for f in labels["files"] } bad = [] for file_id, keys in want.items(): for key in keys: if by_file.get(file_id, {}).get(key) != "value": bad.append(f"{file_id}::{key}") assert not bad, f"곁값이 설명으로 숨었다: {bad}" # 브레인 쪽지는 「16 쯤」이라 적었으나 짚어 준 자리를 세면 17 이다 # (unit 8 · date 2 · scope 3 · basis_unit · 밑수 비고 · 요율 적용여부 2). assert sum(len(v) for v in want.values()) == 17 def test_미결은_미판정으로_표시된다(labels): """값도 설명도 아닌 「미결」류 — 사용자가 트리를 본 뒤 정한다. 갈래를 임의로 못박지 않는다.""" marked = { f"{f['file_id']}::{g['key']}" for f in labels["files"] for g in f["value_groups"] if g.get("undecided") } | { f"{f['file_id']}::{t['key']}" for f in labels["files"] for t in f["tables"] if t.get("undecided") } assert marked == { "timber_structure_class::pending_user", "material_surcharge::not_found", "material_surcharge::candidates_pending_user/items", } column = labels["synthetic_tables"]["@values"]["columns"][3] assert column["key"] == "@undecided" assert column["name_ko"] == "미판정" #: 2026-09-16 사용자 판정 — 기초데이터 아홉이 더해져 열넷. #: 곧이어 코드에 박혀 있던 기계 시공능력을 꺼내 열다섯(브레인 b6fb3725). #: 잣대 = 계산되는 값이 아니라 **그냥 값만으로 정의되는 데이터셋**. MERGED_KEYS = ( "labor", "material", "oil", "rate", "machine", "coef", "material_surcharge", "formwork_reuse", "rebar_complexity", "timber_structure_class", "masonry_slope", "masonry_back_length", "stone_kind", "masonry_class", "machine_productivity", "work_item_forest", # 2026-09-17 공종 축 산림 "work_item_const", # 2026-09-17 공종 축 건설 ) def test_기초데이터_열넷이_다_있다(labels): merged = {m["key"]: m for m in labels["merged_tables"]} assert set(merged) == set(MERGED_KEYS) for entry in merged.values(): assert entry["name_ko"].strip() assert entry["summary"].strip() assert entry["columns"], entry["key"] assert entry["sources"], entry["key"] def test_합친_표의_열마다_한글_이름이_있다(labels): """⚠ 「고칠 수 있나」는 여기서 안 정한다 — 서버 `spec()` 한 곳(판정 두 벌 금지). 영문으로 떨어지는 열이 있는지는 `test_master_labels_base_prices.py` 가 응답으로 잰다.""" bad = [ f"{entry['key']}/{column['key']}" for entry in labels["merged_tables"] for column in entry["columns"] if not column["name_ko"].strip() ] assert not bad, bad def test_기계만_다른_표의_값을_문다(labels): """⭐ 기계 시간당 단가는 유가·노임 표를 물어 나온다 — 갱신 차례가 여기서 갈린다.""" merged = {m["key"]: m for m in labels["merged_tables"]} machine = {c["key"]: c for c in merged["machine"]["columns"]} for key in ("fuel_price_krw_per_l", "operator_daily_wage_krw"): assert key in machine, f"{key} 이름표가 없다" assert "표" in machine[key].get("from", ""), f"{key} 가 어디서 오는지 안 적혔다" for key in ("labor", "material", "oil", "rate"): assert len(merged[key]["sources"]) <= 3, key def test_기계_계산_사슬이_다섯_걸음으로_적혀_있다(labels): chain = labels["machine_cost_chain"] assert len(chain["steps"]) == 5 for step in chain["steps"]: assert step["step"].strip() and step["formula"].strip() assert "밑값" in chain["editable_rule"] and "계산값" in chain["editable_rule"] # 기계는 유가·노임 표를 문다 — 갱신 차례가 여기서 갈린다. assert "유가" in chain["cross_table"] and "노임" in chain["cross_table"] def test_열_맞대기_어긋남이_네_갈래로_적혀_있다(labels): findings = labels["merge_findings"] assert set(findings) == { "renamed_same_column", "one_side_only", "unit_or_axis_mismatch", "collision", } assert all(findings[k] for k in findings) def test_줄_열쇠가_다섯_다_판정돼_있다(labels): """덮개가 원본 줄을 붙드는 끈 — 없는 자료는 「없음」으로 내고 지어내지 않는다.""" keys = {r["table"]: r for r in labels["row_keys"]} assert set(keys) == set(MERGED_KEYS) for entry in keys.values(): assert entry["verdict"].strip() and entry["key"] and entry["checked"].strip() assert keys["material"]["key"] == ["item_code"] assert keys["machine"]["key"] == ["machine_code"] # 한 칸으로는 안 되는 둘 — 묶어야 한다. assert len(keys["labor"]["key"]) > 1 assert keys["oil"]["verdict"].startswith("없음") assert keys["rate"]["verdict"].startswith("없음") def test_null_이_제값인_열이_적혀_있다(labels): """되돌리기 null 과 원본의 「값 없음」이 겹치는 자리 — 겹치면 미조사가 값 지움으로 바뀐다.""" block = labels["null_is_real"] assert block["columns"] for row in block["columns"]: assert row["table"] in MERGED_KEYS assert row["column"].strip() and row["why"].strip() assert isinstance(row["rows"], int) # 가장 위험한 자리 — 노임 일 노임이 없는 줄(미조사) wage = [ r for r in block["columns"] if r["table"] == "labor" and "daily_wage_krw" in r["column"] ] assert wage and wage[0]["rows"] == 30 assert "되돌리기" in block["rule"]