Files
Aislo/resources/tester/test_master_labels_base_prices.py
T
eomsangdonandClaude Opus 5 ede97d30fb feat(z01): 기초데이터 아홉 이름표 — 열 21칸 · 줄 열쇠 아홉 · 축 시험 셋
- kind 아홉을 이름표 `merged_tables` 에 더함(다섯 → **열넷**) — coef · material_surcharge ·
  formwork_reuse · rebar_complexity · timber_structure_class · masonry_slope ·
  masonry_back_length · stone_kind · masonry_class. 영문으로 뜨던 21칸 **0**
- ⚠ 구간 이름은 **원문 표기 그대로**(`~1.5`·`~3`·`~5`·`~7`·`7이상` · 돌 직경 `40~60`) —
  열쇠의 몸이라 예쁘게 고치면 덮개가 주인을 잃음. 시험이 그 표기를 박아 둠
- 축 칸(`table`·`bond`·`face`·`height_bracket`·`stone_kind`·`back_length_cm`·`class`·
  `soil_type`·`type_id`·`material`)은 이름에 「줄을 가리는 축 — 못 고침」을 적음
- `coef/source_rows`·`duplicate_note` 는 암괴 중복 두 줄을 한 줄로 합친 사유 칸으로 이름 붙임
- 줄 열쇠 아홉을 `row_keys` 에 더함 — 서버가 줄마다 내는 `@axis` 가 곧 열쇠 ·
  넷은 **id 칸이 없어 축으로만 가려짐**(masonry_slope·masonry_back_length·stone_kind·masonry_class)
- 시험 셋 더함(12건) — 이름표 축이 서버 `@axis` 를 다 덮는지 · 구간 표기가 원문 그대로인지 ·
  축 칸이 고칠 수 있는 칸으로 서 있지 않은지
- ⚠ 예외 하나 적어 둠 — `formwork_reuse.reuse_count` 는 갈래표에서는 **값**, 비율표에서는 **축**이라
  서버가 한 판정만 내는 지금은 고칠 수 있는 칸으로 섬(판정 대기)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFCnEYNH4tsS2MbHvzBhZk
2026-09-16 13:12:58 +09:00

212 lines
8.4 KiB
Python

"""기초단가 다섯 표 — **열 이름이 영문으로 떨어지면 빨강**.
이 시험이 있는 까닭(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[<kind>].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