"""785-1(642-1) 지킴 — 블록 글 → 절 나무 · 상수 · 대입식 · 절 몫(`M02_Hopyo_Formula`) · 북부청 견본 엑셀. 조항 색인 → 기초 후보(`Clause.basis` · `index`) 시험은 809-1 에서 지움(경쟁사 로직 인정 · 요구 703). 읽기만 · 서버 없음. """ from __future__ import annotations import sys import warnings from functools import lru_cache from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Formula as fm # noqa: E402 warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl") XLS = ( ROOT / "resources/knowledge/original/실무문서/2026_실무일위대가 모음" / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls" ) @lru_cache(maxsize=1) def blocks() -> dict[int, dict]: if not XLS.exists(): pytest.skip("견본 엑셀 없음") book = hx.read_book(XLS.name, XLS.read_bytes()) return {b["번"]: b for b in book["단산"]} # ── 절 나무 ─────────────────────────────────────────────────────────── def _works(no: int) -> list[str]: nodes = blocks()[no]["절"] ids = {id(w) for w in fm.works(nodes)} return [p for p, one in _paths(nodes) if id(one) in ids] def _paths(nodes: list[dict], up: str = "") -> list[tuple[str, dict]]: out = [] for one in nodes: path = f"{up}.{one['번']}" if up else one["번"] out += [(path, one), *_paths(one["아래"], path)] return out def test_절_나무(): assert _works(47) == ["1", "2"] assert _works(6) == ["1.가", "1.나", "1.다", "3"] assert [n["번"] for n in blocks()[6]["절"]] == ["1", "3", "4"] # 「2.」 빠짐 그대로 assert _works(24) == [] # 재료비 · 인건비 · 합계뿐 = 블록 통째 b = blocks()[47] assert len(b["글행"]) == len(b["글"]) one = fm.find(b["절"], "1") assert one["소계"] == {"노무비": 217540.1, "재료비": 19578.6, "경비": None, "계": 237118.7} lo, hi = b["행"] assert all(lo < r <= hi for r in one["행"]) # ── 상수 · 대입식 · 몫 ───────────────────────────────────────────────── def _consts(no: int, path: str) -> dict[str, dict]: return {c["기호"]: c for c in fm.find(blocks()[no]["절"], path)["상수"]} def test_상수(): c = _consts(10, "1") assert (c["L"]["값"], c["L"]["단위"]) == (6, "m") assert (c["경사도"]["값"], c["경사도"]["단위"]) == (35, "°") assert (c["n"]["값"], c["n"]["이름"]) == (42, "운반회수") c = _consts(6, "1.나") assert {k: c[k]["값"] for k in ("q", "K", "f", "E", "Cm")} == { "q": 0.6, "K": 0.55, "f": 1, "E": 0.35, "Cm": 18, } assert (c["q"]["단위"], c["Cm"]["단위"], c["Cm"]["덧"]) == ("㎥", "sec", "90˚") assert isinstance(c["q"]["행"], int) def test_대입식(): p = next(f for f in fm.find(blocks()[10]["절"], "1")["식"] if f["기호"] == "P") assert p["꼴"] == "곱" and p["값"] == 0.547619 assert [(f["수"], f["단위"]) for f in p["분자"]] == [ (1, "㎡"), (575, "㎏/㎡"), (1, "회"), (1, "일"), ] assert p["분모"] == [{"수": 25, "단위": "㎏"}, {"기호": "n", "단위": ""}] assert p["대입"] == {"n": 42} def test_절_몫(): nodes = blocks()[6]["절"] share = fm.find(nodes, "1.나")["몫"] assert share["글"] == "× 575 / 1750 × 90%" assert share["값"] == pytest.approx(575 / 1750 * 0.9, abs=1e-6) assert fm.find(nodes, "1.가")["몫"]["값"] == 0.1 assert fm.find(nodes, "3")["몫"]["값"] == pytest.approx(575 / 1750, abs=1e-6) assert fm.find(blocks()[47]["절"], "1")["몫"] is None def test_인자_괄호(): got_ = fm.factors("Cm × n / (60 × Es)") assert [(w, f.get("기호") or f.get("수")) for w, f in got_] == [ ("곱", "Cm"), ("곱", "n"), ("나눔", 60), ("나눔", "Es"), ] assert fm.factors("t1 + t2") is None and fm.factors("1 2") is None