- `merged_tables` 다섯(노임·자재·유가·제비율·기계) — 열 62. **값은 안 옮김**, 열 이름·단위·숨김과 `editable`(고칠 수 있나)만 담음. 계산값 넷은 기계에만 있고 `editable: false` - `machine_cost_chain` 다섯 걸음 — 손료(취득가×1,000×손료계수) · 연료(연료량×유가×(1+잡재료)) · 조종원(일 노임÷8×제수당) · 암 할증 · 시간당 기계경비. **밑값 11 / 계산값 4** 를 가름 · 기계가 유가·노임 표를 물어 갱신 차례가 갈림을 적음 - `merge_findings` 네 갈래 — 이름 다른 같은 열 2 · 한쪽에만 있는 열 4 · 단위·축 어긋남 4 · 부딪힘 2 - `row_keys` 다섯 판정(브레인 ①) — 자재 `item_code`·기계 `machine_code` 는 전수 겹침 0 으로 **있음** · 노임은 두 코드 공간이라 `출처+직종코드` 로 묶어야 함 · 유가·제비율은 **줄 열쇠 없음**(지어내지 않음) - `null_is_real` 여섯(브레인 ②) — ⭐ 노임 일 노임이 **30 줄에 아예 없음**(미조사가 제값) · 기계 운전경비에 **명시 null 12** · 되돌리기 null 과 뜻이 겹치는 자리를 규칙과 함께 적음 - 시험 36건(8건 늘) — 계산값이 기계 밖으로 새거나 줄 열쇠·null 판정이 빠지면 빨강 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk
541 lines
21 KiB
Python
541 lines
21 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_값_묶음마다_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"] == "미판정"
|
|
|
|
|
|
MERGED_KEYS = ("labor", "material", "oil", "rates", "machine")
|
|
|
|
|
|
def test_기초단가_다섯이_다_있다(labels):
|
|
merged = {m["key"]: m for m in labels["merged_tables"]}
|
|
assert tuple(merged) == 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):
|
|
bad = []
|
|
for entry in labels["merged_tables"]:
|
|
for column in entry["columns"]:
|
|
if not column["name_ko"].strip():
|
|
bad.append(f"{entry['key']}/{column['key']} — 이름 없음")
|
|
if not isinstance(column.get("editable"), bool):
|
|
bad.append(f"{entry['key']}/{column['key']} — editable 없음")
|
|
assert not bad, bad
|
|
|
|
|
|
def test_계산값은_기계에만_있고_못_고친다(labels):
|
|
"""⭐ 밑값만 고칠 수 있다 — 계산값을 손으로 고치면 밑값과 어긋나 되짚을 수 없다."""
|
|
derived = {
|
|
entry["key"]: [c["key"] for c in entry["columns"] if not c["editable"]]
|
|
for entry in labels["merged_tables"]
|
|
}
|
|
assert derived["machine"] == [
|
|
"hourly_loss_krw",
|
|
"hourly_fuel_krw",
|
|
"hourly_operator_krw",
|
|
"hourly_total_krw",
|
|
]
|
|
for key in ("labor", "material", "oil", "rates"):
|
|
assert derived[key] == [], f"{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["rates"]["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"]
|