"""준비공·사방공 검사 — PLAN 8-3 의 ❌ 둘 (2026-09-07 ⑪). 여기서 하는 일은 **줄을 세우고 못 서는 줄의 사유를 적는 것**이다. 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. ⚠⚠ 벌목은 이미 토공집계의 「지장목제거」로 선다 — 여기서 또 세우면 같은 나무를 두 번 벤다. """ from __future__ import annotations import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 EROSION_CONTROL_TYPES, STATUS_COUNTED_ELSEWHERE, STATUS_NOT_APPLICABLE, STATUS_PENDING, build_table, erosion_rows, ) def 줄(table: dict, item: str) -> dict: return next(row for row in table["rows"] if row["item"] == item) # ── 이중계상 방어 ─────────────────────────────────────────────────── def test_벌목은_값을_내지_않고_가리키기만_함() -> None: """⚠ 이 파일의 핵심 — 토공집계의 「지장목제거」와 겹치면 같은 나무를 두 번 벤다.""" table = build_table({"tree_removal_fill": 1000.0, "tree_removal_cut": 500.0}) row = 줄(table, "벌목·지장목제거") assert row["amount"] is None # 값은 안 낸다 assert row["status"] == STATUS_COUNTED_ELSEWHERE assert "이중계상" in row["reason"] # 참고 면적은 보인다 — 어느 값과 겹치는지 사람이 알아야 한다. assert row["reference_amount"] == pytest.approx(1500.0) # ── 못 서는 줄은 사유를 적는다 ───────────────────────────────────── def test_못_서는_줄이_목록에_남을것() -> None: """빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다.""" table = build_table() items = [row["item"] for row in table["rows"]] assert "표토제거" in items and "제근·뿌리다듬기" in items # 규준틀은 둘로 갈렸다 — 비탈(11-2)과 수평(11-3)의 설치 기준이 다르다. assert "비탈 규준틀" in items and "수평 규준틀" in items def test_사유가_비어_있지_않을것() -> None: for row in build_table()["rows"]: assert row["reason"], f"{row['item']} 에 사유가 없다" def test_값이_없어도_공종코드는_알려줄것() -> None: """무엇으로 서게 될 줄인지 알아야 나중에 잇는다.""" assert 줄(build_table(), "표토제거")["work_item_code"] == "FP-09-15" # ── 사방공 ────────────────────────────────────────────────────────── def test_사방_시설이_없으면_해당_없음() -> None: """있는 것처럼 0 을 적지 않는다.""" row = 줄(build_table(structures=[{"type_id": "masonry_wet"}]), "사방 시설") assert row["status"] == STATUS_NOT_APPLICABLE assert row["amount"] is None def test_사방_시설이_있으면_줄이_섬() -> None: table = build_table(structures=[{"type_id": "erosion_check"}, {"type_id": "revetment"}]) 사방 = [row for row in table["rows"] if row["group"] == "사방공"] # 키는 `type_id` 칸으로 오가고, 화면에 뜨는 `item` 은 사람 이름이다. assert [row["type_id"] for row in 사방] == ["erosion_check", "revetment"] assert [row["item"] for row in 사방] == ["골막이", "기슭막이"] assert all(row["status"] == STATUS_PENDING for row in table["rows"] if row["group"] == "사방공") def test_사방_종류_이름이_레지스트리와_맞을것() -> None: """⚠ 이름을 지어내면 영영 안 걸린다 — 레지스트리의 실제 `type_id` 여야 한다.""" import json registry = json.loads( (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") ) types = registry if isinstance(registry, list) else (registry.get("types") or []) known = {t["type_id"] for t in types} assert EROSION_CONTROL_TYPES <= known, EROSION_CONTROL_TYPES - known def test_셈이_맞을것() -> None: table = build_table() assert table["row_count"] == len(table["rows"]) assert table["pending_count"] == sum(1 for r in table["rows"] if r["status"] == STATUS_PENDING) # ── 화면에 안 보이던 확정 항목을 드러내기 (2026-09-07) ───────────── # # ⚠ 목록에만 있고 화면에 없으면 **사용자는 그것이 잠정인 줄도 모른다.** # 「확인 필요」만 있어도 안 된다 — **지금 무슨 값으로 돌고 있는지**를 함께 적어야 # 사용자가 무엇을 정할지 안다(원단위 미확보에서 「표에 있는 규격을 함께 알린」 그 방식). def test_지장목제거_줄에_공종_미확정_사유가_적힐것() -> None: row = 줄(build_table(), "벌목·지장목제거") assert "공종 미확정" in row["reason"] assert "수확베기" in row["reason"] # 후보가 무엇인지도 함께 보인다 # ── 규준틀 개소 — 원문이 기준을 정해 둠 (2026-09-07 ㉒) ──────────── # # 11-2 [주]① 「비탈길이 10m 이상 20m마다 설치한다」 # 11-3 [주]① 「중심점에서 성토 높이 5m 이상에 설치한다」 # ⚠ 재료량은 [주]④ 「설계수량에 따른다」 — **개소만 내고 재료는 미확보**. def 사면줄(*pairs: tuple[float, float]) -> list[dict]: """(그 측점의 사면길이, 앞 측점까지 거리) 목록.""" return [ {"lengths": {"face_dressing_fill": length}, "distance_m": dist} for length, dist in pairs ] def test_비탈길이_10m_이상_구간만_셀것() -> None: """⚠ 「10m 이상」은 **비탈길이** 조건이고 「20m마다」는 **노선 거리** 간격이다. 둘을 섞으면 짧은 사면 구간까지 세어 개소가 부푼다.""" from B08_Quantity.B08_Quantity_Engine_Preparation import batter_frame_count # 사면길이 12m 인 구간 40m + 사면길이 3m 인 구간 100m → 40m 만 센다. count, notes = batter_frame_count(사면줄((12.0, 20.0), (12.0, 20.0), (3.0, 100.0))) assert count == 3 # 40 ÷ 20 + 1 assert "11-2" in notes[0] def test_긴_사면이_없으면_안_설것() -> None: from B08_Quantity.B08_Quantity_Engine_Preparation import batter_frame_count count, notes = batter_frame_count(사면줄((4.0, 100.0))) assert count == 0 assert notes def test_비탈_규준틀이_표에_값으로_설것() -> None: # ⚠ 실제 사면표는 **첫 측점의 distance_m 이 0** 이다(앞 측점이 없다). 그 모양으로 넣는다. table = build_table(slope_rows=사면줄((12.0, 0.0), (12.0, 20.0))) row = 줄(table, "비탈 규준틀") assert row["amount"] == 2.0 # 20 ÷ 20 + 1 assert row["status"] == "값 있음" # 재료는 여전히 미확보임을 같은 줄에 적는다. assert "설계수량" in row["reason"] def test_수평_규준틀은_성토고_칸이_없으면_못_셈() -> None: """⚠ 0 으로 때우지 않는다 — 「없음」과 「못 셈」은 다르다.""" row = 줄(build_table(slope_rows=[{"lengths": {}, "distance_m": 20.0}]), "수평 규준틀") assert row["amount"] is None assert "성토고" in row["reason"] and "11-3" in row["reason"] def test_수평_규준틀은_성토고_5m_이상_측점마다() -> None: """⚠ 비탈규준틀(「10m 이상 20m마다」)과 **기준이 다르다** — 이쪽은 **지점 조건**이라 간격이 없다. 두 기준을 같은 식으로 쓰면 조용히 틀린다.""" rows = [ {"fill_height_m": 6.0, "distance_m": 20.0}, {"fill_height_m": 2.0, "distance_m": 20.0}, {"fill_height_m": 5.0, "distance_m": 20.0}, ] row = 줄(build_table(slope_rows=rows), "수평 규준틀") assert row["amount"] == 2.0 # 6.0 · 5.0 두 곳(5.0 은 「이상」이라 든다) assert row["status"] == "값 있음" def test_성토고가_다_낮으면_0개소() -> None: """0 이 **값**인 자리다 — 「못 셈」과 달리 「없다」가 맞는 답이다.""" row = 줄(build_table(slope_rows=[{"fill_height_m": 1.0, "distance_m": 20.0}]), "수평 규준틀") assert row["amount"] == 0.0 assert row["status"] == "값 있음" # ── 표토제거 두께 — 품셈이 아니라 설계가 정함 (2026-09-07 ㉓) ─────── # # 9-15 [주]② 「Q=Q1/T … T : 표토두께(m)」 — **공식의 입력 변수**다. # ⚠ 안 넣으면 0 으로 때우지 않고 물량을 안 낸다. def test_두께가_없으면_물량을_안_낼것() -> None: row = 줄(build_table({"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}), "표토제거") assert row["amount"] is None # ⚠ 사유가 정정됐다(2026-09-09) — 종전에 「품셈이 정하는 값이 아닙니다」라고만 적었는데 # 품셈 9-15-2(답외구간) 원문에 **T=0.2m · L=20m 가 적용값으로 박혀 있다.** 두께를 # 자동으로 넣지는 않되 **원문에 있는 값을 없다고 말하지도 않는다.** assert "두께는 설계가 정하는 값" in row["reason"] assert "T=0.2m" in row["reason"] and "9-15-2" in row["reason"] # 대상 면적은 참고로 보인다 — 무엇에 곱할지 사용자가 알아야 한다. assert row["reference_amount"] == pytest.approx(1500.0) def test_두께를_넣으면_물량이_설것() -> None: table = build_table( {"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, topsoil_thickness_m=0.15 ) row = 줄(table, "표토제거") assert row["amount"] == pytest.approx(225.0) # 1,500㎡ × 0.15m assert row["status"] == "값 있음" def test_두께가_0이면_안_낼것() -> None: """0 은 「안 넣음」과 같게 본다 — 0㎥ 를 내면 「없다」로 오해된다.""" row = 줄(build_table({"face_dressing_fill": 1000.0}, topsoil_thickness_m=0), "표토제거") assert row["amount"] is None def test_erosion_row_uses_registry_name_not_type_id(): """사방공 줄 이름에 개발자 키가 새지 않는다 (2026-09-08 ㉕ 통과에서 `soil_guard` 로 뜬 자리).""" rows = erosion_rows([{"type_id": "soil_guard"}], {"soil_guard": "흙막이"}) assert [row["item"] for row in rows] == ["흙막이"] assert rows[0]["type_id"] == "soil_guard" def test_erosion_row_falls_back_to_wording_table_without_registry(): """이름표가 없어도 문구표가 받아 준다 — 없으면 키를 보이되 지어내지 않는다.""" assert erosion_rows([{"type_id": "soil_guard"}])[0]["item"] == "흙막이" assert erosion_rows([{"type_id": "erosion_check"}])[0]["item"] == "골막이"