"""기초단가 다섯 표 — **열 이름이 영문으로 떨어지면 빨강**. 이 시험이 있는 까닭(2026-09-15 크로스체크) — 세 창이 각자 초록이었는데도 **26칸이 화면에 영문 key 로 뜨고 있었다.** 서버는 이름표에서 못 찾으면 영문 key 로 조용히 떨어지게 짜여 있고, 이름표 쪽 시험은 서버가 실제로 보는 자리를 안 봤다. **어긋나도 아무도 모르는 자리**였다. ⚠ 그래서 이 시험은 이름표 파일만 읽지 않고 **서버가 내는 응답 그대로** 잰다. """ from __future__ import annotations import json from pathlib import Path import pytest from Z01_MasterData import Z01_MasterData_BasePrices as base_prices ROOT = Path(__file__).resolve().parents[2] LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.json" @pytest.fixture(scope="module") def labels() -> dict: return json.loads(LABELS_PATH.read_text(encoding="utf-8")) @pytest.fixture(scope="module") def shown() -> dict[str, list[dict]]: """서버가 실제로 내보내는 열 — 이름표 파일이 아니라 **응답**을 잰다.""" out = {} for kind in base_prices.KINDS: columns = base_prices._columns(kind, base_prices.rows(kind)) out[kind] = [base_prices.column_meta(kind, c) for c in columns] return out def test_다섯_표가_다_선다(shown): # 2026-09-16 기초데이터 아홉이 더해져 열넷(사용자 지시 · 브레인) — 다섯은 그 안에 그대로 assert {"labor", "machine", "material", "oil", "rate"} <= set(shown) == set(base_prices.KINDS) for kind, columns in shown.items(): assert columns, f"{kind} 에 열이 하나도 없다" def test_영문으로_떨어지는_열이_하나도_없다(shown): """⭐ 이 시험이 이번 판의 값진 자리 — 이름표가 서버 틀과 어긋나면 여기서 빨강.""" english = [ f"{kind}/{column['key']}" for kind, columns in shown.items() for column in columns if column["label"] == column["key"] ] assert not english, ( f"한글 이름이 없어 영문 key 로 뜨는 열 {len(english)}개: {english}\n" "→ 이름표 `merged_tables[].columns` 에 그 key 를 더할 것." ) def test_열_이름이_비지_않았다(shown): blank = [ f"{kind}/{column['key']}" for kind, columns in shown.items() for column in columns if not str(column["label"]).strip() ] assert not blank, blank def test_이름표에_묵은_열이_남아_있지_않다(labels, shown): """서버가 안 내는 열을 이름표가 들고 있으면 옛 틀이 굳은 것이다.""" stale = [] for entry in labels["merged_tables"]: kind = entry["key"] live = {c["key"] for c in shown.get(kind, ())} for column in entry["columns"]: if column["key"] not in live: stale.append(f"{kind}/{column['key']}") assert not stale, f"서버가 안 내는 묵은 열 이름표: {stale}" def test_요율_축이_한_칸으로_안_합쳐졌다(shown): """⭐ 2026-09-15 브레인 ④ — 합치면 어느 축의 구간인지 사라지고 `@id` 도 흔들린다. 축은 서버가 원문 칸 그대로 펴고 이름표가 거기에 맞춘다. """ keys = {column["key"] for column in shown["rate"]} for axis in ( "grade", "year", "work_type", "target_amount_bracket", "estimated_amount_bracket", "estimated_price_bracket", "direct_cost_bracket", "duration_bracket", ): assert axis in keys, f"요율 축 {axis} 가 사라졌다" def test_요율_줄_열쇠에_축이_살아_있다(): """`@id` 가 `{변수}[/{목록}/{축=값;…}]` 꼴 — 축이 죽으면 고친 값이 주인을 잃는다.""" rows = base_prices.rows("rate") multi = [r for r in rows if "/" in str(r["@id"])] assert multi, "축이 든 줄 열쇠가 하나도 없다" assert any("=" in str(r["@id"]) for r in multi) assert len({r["@id"] for r in rows}) == len(rows), "줄 열쇠가 겹친다" def test_기계_시간당_단가는_계산값으로_잠긴다(shown): """밑값만 고칠 수 있다 — 판정은 서버 `spec()` 한 곳(이름표가 따로 안 정한다).""" meta = base_prices.table("machine", page=1, size=1) assert set(meta["formula"]) == { "hourly_loss_krw", "hourly_fuel_krw", "hourly_operator_krw", "hourly_total_krw", } for key in meta["formula"]: assert key not in meta["editable"], f"{key} 는 계산값인데 고칠 수 있다" def test_이름표는_고칠수있나를_안_정한다(labels): """판정 두 벌 금지 — `editable` 은 서버만 정한다.""" carried = [ f"{entry['key']}/{column['key']}" for entry in labels["merged_tables"] for column in entry["columns"] if "editable" in column ] assert not carried, f"이름표가 고칠 수 있나를 들고 있다: {carried}" def test_단위가_붙어야_할_열에_단위가_있다(shown): """「유가 1,858」만 떠서는 리터인지 드럼인지 모른다 — 금액·수량 열은 단위를 단다.""" want = { "labor": {"daily_wage_krw": "원", "hours_per_day": "시간"}, "material": {"price_krw": "원"}, "oil": {"price_krw_per_l": "원/L"}, "machine": { "price_thousand_krw": "천원", "fuel_rate_l_per_hour": "L", "fuel_price_krw_per_l": "원/L", "hourly_total_krw": "원", }, "rate": {"rate_percent": "%", "base_amount_krw": "원"}, } bad = [] for kind, expected in want.items(): units = {c["key"]: c["unit"] for c in shown[kind]} for key, unit in expected.items(): if units.get(key) != unit: bad.append(f"{kind}/{key}: {units.get(key)!r} ≠ {unit!r}") assert not bad, bad def test_줄_열쇠가_서버_축과_같다(labels): """⭐ 덮개가 줄을 붙드는 끈 — 서버가 줄마다 `@axis` 로 내는 축이 곧 열쇠다. 이름표가 축을 빠뜨리면 고친 값이 갱신 뒤 주인을 잃는다. """ declared = {r["table"]: set(r["key"]) for r in labels["row_keys"]} bad = [] for kind in base_prices.KINDS: axes = set() for row in base_prices.rows(kind): axes |= set(row.get("@axis") or ()) if not axes: # 다섯 표는 `@id` 한 칸이라 축을 안 낸다 continue missing = axes - declared.get(kind, set()) if missing: bad.append(f"{kind}: 이름표에 없는 축 {sorted(missing)}") assert not bad, bad def test_구간_이름은_원문_표기_그대로다(): """⚠ `~1.5`·`7이상` 을 예쁘게 고치면 줄 열쇠가 바뀌어 덮개가 주인을 잃는다.""" for kind in ("masonry_slope", "masonry_back_length"): brackets = {str(r.get("height_bracket")) for r in base_prices.rows(kind)} assert brackets == {"~1.5", "~3", "~5", "~7", "7이상"}, (kind, sorted(brackets)) classes = { str(r.get("class")) for r in base_prices.rows("masonry_class") if r.get("table") == "boulder_diameter" } assert classes == {"40~60", "60~80", "80~100"}, sorted(classes) #: ⚠ **줄마다 축이기도 값이기도 한 칸** — `formwork_reuse.reuse_count` 는 #: `reuse_by_class` 줄에서는 **고치는 값**(갈래별 사용횟수)이고 #: `reuse_ratio_pct` 줄에서는 **축**(몇 회째인가)이다. 서버는 열 하나에 한 판정만 내므로 #: 지금은 고칠 수 있는 칸으로 서 있다. 비율표 줄에서 이 칸을 고치면 **다른 줄로 옮겨 간다**. #: 2026-09-16 크로스체크에서 드러났고 판정 대기 — 고치기 전까지 여기 예외로 적어 둔다. AXIS_ALSO_VALUE = {"formwork_reuse/reuse_count"} def test_축_칸은_못_고친다(): """축을 고치면 줄이 다른 줄이 된다 — 서버가 잠가야 한다.""" bad = [] for kind in base_prices.KINDS: rows = base_prices.rows(kind) axes = set() for row in rows: axes |= set(row.get("@axis") or ()) if not axes: continue meta = base_prices.table(kind, page=1, size=1) for axis in axes: if axis in meta["editable"] and f"{kind}/{axis}" not in AXIS_ALSO_VALUE: bad.append(f"{kind}/{axis} 가 고칠 수 있는 칸으로 섰다") assert not bad, bad def test_기초단가_상자_이름이_이름표에_있다(labels): """⭐ 서버가 내는 갈래(`group`)마다 한글 이름이 있어야 화면이 영문으로 안 뜬다. 서버가 읽을 자리는 `labels["base_price_groups"][group]["name_ko"]` 다 — 두 창이 서로 다른 자리를 보면 2026-09-15 의 26칸 사고가 되풀이된다. """ named = labels["base_price_groups"] groups = {base_prices.kind_group(kind) for kind in base_prices.KINDS} missing = groups - set(named) assert not missing, f"이름표에 없는 갈래: {sorted(missing)}" for key, entry in named.items(): assert entry["name_ko"].strip(), key assert entry["summary"].strip(), key assert named["base_price"]["name_ko"] == "기초단가" assert named["pumsem_basis"]["name_ko"] == "품셈 기준" def test_상자마다_kind_가_있다(): """빈 상자가 서면 화면에 이름만 뜨고 아무것도 안 열린다.""" counted: dict[str, int] = {} for entry in base_prices.kinds(): counted[entry["group"]] = counted.get(entry["group"], 0) + 1 assert entry["label"] != entry["kind"], f"{entry['kind']} 이름이 영문 그대로다" assert entry["rows"] > 0, entry["kind"] assert all(n > 0 for n in counted.values()), counted