"""비탈면 녹화 ㎡ 넷 — 새심기·평떼·줄떼·비탈덮기 (2026-09-15 브레인 12장 D 녹화 ⓐ~ⓓ). 근거 산림품셈 5-14 새심기(㎡당) · 5-22 평떼(떼 0.3×0.3 11매/㎡ · 붙임 ㎡ · 떼꽂이 ㎡) · 5-23 줄떼(떼 3.33매/㎡ · 붙임 ㎡ · 떼꽂이 ㎡) · 5-25 거적덮기(㎡당) · 5-28 비탈덮기(짚망 · 매트) 판정 — 면적 = 구간 안 사면 면적(칸: 절토면/성토면/양쪽 · 기본값 없음) · 초류종자 살포는 공법에서 뺌 (토공집계가 5-24 로 사면 전체를 셈) · 단 직고·선떼 급수 칸은 기본값 없음 · 길이형은 다음 차례. """ from __future__ import annotations import pytest from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revegetation import ( revegetation_from_designs, ) AREAS = {"b06_cut_area_m2": 40.0, "b06_fill_area_m2": 60.0} def _row(**options) -> dict: structure = { "structure_id": "g1", "type_id": "revegetation", "start_m": 0.0, "end_m": 20.0, "options": {**AREAS, **options}, } table = build_table([structure], {"revegetation": "비탈면 녹화"}) return table def _parts(table: dict) -> dict: row = next( r for r in build_handoff(unit_quantity_table=table)["work_items"] if "녹화" in r["name"] ) return {part["code"]: part for part in row["composite_parts"] or []} def test_평떼_양쪽은_면적과_떼_11매() -> None: table = _row(method="평떼", face="양쪽") row = table["structures"][0] got = {(c["name"], c["unit"]): c["amount"] for c in row["components"]} assert (row["billing_unit"], row["billing_quantity"]) == ("㎡", pytest.approx(100.0)) assert got[("평떼", "㎡")] == pytest.approx(100.0) assert got[("떼", "매")] == pytest.approx(1100.0) text = " ".join(row["notes"]) # 병행시공(임도기술교본 7장 · 울진1공구) — 흠이 아니라 정보(⚠ 안 붙임 · 안티그래비티 확인) assert row["notes"][0].startswith("녹화와 초류종자살포는 병행시공(임도기술교본 7장)") assert "떼 면적을 파종에서 빼지 않음" in text and "⚠" not in row["notes"][0] assert "5-12 품에 걸린 것으로 읽음 — 5-22-3 떼꽂이는 따로 셈" in text parts = _parts(table) assert parts["FP-05-22-02"]["quantity"] == pytest.approx(100.0) assert parts["FP-05-22-03"]["quantity"] == pytest.approx(100.0) def test_줄떼_절토면은_3_33매() -> None: table = _row(method="줄떼", face="절토면") got = {(c["name"], c["unit"]): c["amount"] for c in table["structures"][0]["components"]} assert got[("줄떼", "㎡")] == pytest.approx(40.0) assert got[("떼", "매")] == pytest.approx(40.0 * 3.33) assert set(_parts(table)) == {"FP-05-23-02", "FP-05-23-03"} def test_새심기_성토면() -> None: table = _row(method="새심기", face="성토면") assert _parts(table)["FP-05-14"]["quantity"] == pytest.approx(60.0) def test_비탈덮기는_덮개를_골라야_선다() -> None: table = _row(method="비탈덮기", face="양쪽", cover_kind="거적") assert _parts(table)["FP-05-25"]["quantity"] == pytest.approx(100.0) unset = _row(method="비탈덮기", face="양쪽")["structures"][0] assert not unset["components"] and any("덮개" in n for n in unset["notes"]) def test_면을_안_고르면_사유() -> None: row = _row(method="평떼")["structures"][0] assert not row["components"] and any("절토면" in n and "성토면" in n for n in row["notes"]) def test_조공은_아직_산출식이_없다() -> None: row = _row(method="조공", face="양쪽")["structures"][0] assert not row["components"] assert any("산출식 아직 없음" in n for n in row["notes"]) SOD = { "method": "선떼붙이기", "face": "양쪽", "step_height_m": 1.5, "sod_grade": "5급", "sod_size": "대형 40×40", "cutting_soil": "보통토사", # 구간 안 사면 낙차 × 거리(m·m) — 절토 30 · 성토 60 ⇒ 연장 = 90 ÷ 1.5 = 60m "b06_cut_rise_area_m2": 30.0, "b06_fill_rise_area_m2": 60.0, } def test_선떼는_낙차_나누기_단_직고로_연장_표_2_2_6_매수() -> None: """품셈 5-16-1 [주]② 연장 = 대상지 직고 ÷ 단 직고 × 연장 · 표 2-2-6 표 값(식 아님).""" table = _row(**SOD) row = table["structures"][0] got = {(c["name"], c["unit"]): c for c in row["components"]} assert (row["billing_unit"], row["billing_quantity"]) == ("m", pytest.approx(60.0)) assert got[("선떼붙이기", "m")]["amount"] == pytest.approx(60.0) # 5급 대형 표 값 7.80매/m(단면식 3.0 × 2.5 = 7.50 과 어긋남 — 표가 이김) assert got[("떼", "매")]["amount"] == pytest.approx(60.0 * 7.80) assert got[("떼", "매")]["spec"] == "40×40" assert got[("떼붙임", "㎡")]["amount"] == pytest.approx(60.0 * 7.80 * 0.16) text = " ".join(row["notes"]) assert "5급 대형 표 7.80 ↔ 단면식 7.50" in text and "소형 2급 표 27.0 ↔ 단면 4.0" in text parts = _parts(table) assert parts["FP-05-16-01#보통토사"]["quantity"] == pytest.approx(60.0) # 떼붙임은 수량만 — 호표가 없어 막힘 + 사유(금액 안 섬) assert parts["FP-05-16-02"]["quantity"] == pytest.approx(60.0 * 7.80 * 0.16) assert parts["FP-05-16-02"]["not_ready"] assert "떼붙임 호표 없음" in parts["FP-05-16-02"]["why"] def test_선떼_소형_2급은_표_값_27() -> None: table = _row(**{**SOD, "sod_grade": "2급", "sod_size": "소형 18×18"}) got = {(c["name"], c["unit"]): c["amount"] for c in table["structures"][0]["components"]} assert got[("떼", "매")] == pytest.approx(60.0 * 27.0) assert got[("떼붙임", "㎡")] == pytest.approx(60.0 * 27.0 * 0.18 * 0.18) @pytest.mark.parametrize("missing", ["step_height_m", "sod_grade", "sod_size"]) def test_선떼_칸이_비면_사유(missing: str) -> None: row = _row(**{k: v for k, v in SOD.items() if k != missing})["structures"][0] assert not row["components"] and row["notes"] def test_단끊기_토질을_안_고르면_그_조각만_막힘() -> None: table = _row(**{k: v for k, v in SOD.items() if k != "cutting_soil"}) part = _parts(table)["FP-05-16-01"] assert part["not_ready"] and "토질" in part["why"] def test_구간_안_사면_면적은_B06_측점에서_보간() -> None: designs = [ {"chainage_m": 0.0, "design": {}}, {"chainage_m": 20.0, "design": {}}, ] from B08_Quantity.B08_Quantity_Engine_SlopeLength import StationSlope slopes = [ StationSlope(chainage_m=0.0, cut_length_m=2.0, fill_length_m=4.0), StationSlope(chainage_m=20.0, cut_length_m=4.0, fill_length_m=0.0), ] structure = {"type_id": "revegetation", "start_m": 5.0, "end_m": 20.0, "options": {}} filled = revegetation_from_designs(structure, designs, slopes) # 절토 5→2.5, 20→4 : (2.5+4)/2×15 · 성토 5→3, 20→0 : (3+0)/2×15 assert filled["options"]["b06_cut_area_m2"] == pytest.approx(48.75) assert filled["options"]["b06_fill_area_m2"] == pytest.approx(22.5) def test_구간_안_사면_낙차_면적도_B06_측점에서() -> None: from B08_Quantity.B08_Quantity_Engine_SlopeLength import SlopeSegment, StationSlope def seg(role: str, rise: float) -> SlopeSegment: return SlopeSegment("left", role, 0.0, 1.0, rise, 1.0, 1.0) slopes = [ StationSlope(chainage_m=0.0, segments=(seg("cut", 2.0), seg("fill", -4.0))), StationSlope(chainage_m=20.0, segments=(seg("cut", 4.0), seg("cut", 1.0))), ] structure = {"type_id": "revegetation", "start_m": 0.0, "end_m": 20.0, "options": {}} options = revegetation_from_designs(structure, [], slopes)["options"] assert options["b06_cut_rise_area_m2"] == pytest.approx((2.0 + 5.0) / 2 * 20) assert options["b06_fill_rise_area_m2"] == pytest.approx((4.0 + 0.0) / 2 * 20) def test_등록부_공법에서_초류종자_살포를_빼고_까닭을_보인다() -> None: options = {o.key: o for o in structure_type_map()["revegetation"].options} assert "초류종자 살포" not in options["method"].choices assert "토공집계" in (options["method"].default_basis or "") assert ( options["face"].choices == ["절토면", "성토면", "양쪽"] and options["face"].default is None ) for key in ("step_height_m", "sod_grade", "sod_size", "cutting_soil"): assert options[key].default is None and not options[key].not_in_table assert options["sod_size"].choices == ["대형 40×40", "소형 18×18"] # 단끊기 토질은 품셈 5-16-1 표 갈래 이름 그대로(B06 지반 프리셋과 다른 축) assert options["cutting_soil"].choices == [ "보통토사", "경질ㆍ고사점토 및 자갈섞인 점토", "호박돌 섞인 토사", ]