"""면고르기 줄 — 2026-09-14 브레인 판정 Ⓐ~Ⓓ. 종전: 면적(face_dressing 성·절토)은 있는데 토공집계가 줄을 안 세워 인계가 안 보냄(내역 0줄). 실무 대조: 오솔길 BOM 6벌 면적 셈법 ±0.11% 일치 · 그러나 내역엔 사면적 통째가 아님 (거창 줄 없음 · 영월 성토면 × 50%) — 원문 9-19-1 성토면 [주] 「식재를 위한」 · 9-19-2·3 [주]① 「식재기반 조성에만」 → Ⓐ 밑수 = 초류종자살포(파종) 면적 · 덮어쓰기 칸 · 파종 0 이면 0 + 사유 Ⓑ 절토면 토사/암 갈라 암은 시공법(리핑 9-19-2 · 발파 9-19-3) Ⓒ 토질·시공 칸 둘 · 제안값 없음 Ⓓ 성토면 기계(굴착기 0.6㎥ 형식 둘)는 사유로. """ from __future__ import annotations import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(Path(__file__).resolve().parent)) from test_b08_slope_area import 절토_설계선 # noqa: E402 from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 FACE_DRESSING_CUT_CLASSES, FACE_DRESSING_FILL_CLASSES, SummaryInput, build_rows, build_table, ) from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as slope_table # noqa: E402 from B08_Quantity.B08_Quantity_Engine_SlopeLength import ( # noqa: E402 StationSlope, station_slope, ) CUT_SAND = "절토면 · 모래ㆍ사질토ㆍ점토ㆍ점질토" FILL_CLAY = "성토면 · 인력 · 점토 또는 점질토" # ── 사면길이 · 면적 — 절토면을 토사/암으로 ───────────────────────────── def test_절토_사면길이가_토사_암으로_갈림() -> None: slope = station_slope(20.0, 절토_설계선()) assert slope.cut_soil_length_m + slope.cut_rock_length_m == pytest.approx(slope.cut_length_m) soil = sum(s.length_m for s in slope.segments if s.role == "cut" and s.material == "soil") assert slope.cut_soil_length_m == pytest.approx(soil) and soil > 0 assert slope.cut_rock_length_m > 0 def test_2단이_아닌_측점은_설계_지반_프리셋으로_가르고_모르면_안_가름() -> None: """토사 프리셋은 비탈 전체가 토사 · 암 프리셋 1단은 전체가 암 · 프리셋이 없으면 못 가름.""" one = {**절토_설계선(), "two_stage_slope": False} soil = station_slope(20.0, {**one, "geometry_preset": "soil"}) assert ( soil.cut_soil_length_m == pytest.approx(soil.cut_length_m) and soil.cut_rock_length_m == 0 ) rock = station_slope(20.0, {**one, "geometry_preset": "rock"}) assert ( rock.cut_rock_length_m == pytest.approx(rock.cut_length_m) and rock.cut_soil_length_m == 0 ) unknown = station_slope(20.0, one) assert unknown.cut_length_m > 0 and unknown.cut_soil_length_m == unknown.cut_rock_length_m == 0 def test_면적_합계에_절토면_토사_암이_평균단면적법으로_실림() -> None: one = StationSlope( chainage_m=0.0, cut_length_m=4.0, cut_soil_length_m=3.0, cut_rock_length_m=1.0 ) two = StationSlope( chainage_m=20, cut_length_m=6.0, cut_soil_length_m=2.0, cut_rock_length_m=4.0 ) slopes = [one, two] totals = slope_table(slopes)["totals"] assert totals["face_dressing_cut"] == pytest.approx(100.0) assert totals["face_dressing_cut_soil"] == pytest.approx(50.0) assert totals["face_dressing_cut_rock"] == pytest.approx(50.0) # ── 토공집계 — 밑수 = 파종 면적 ────────────────────────────────────── def _source(**extra) -> SummaryInput: base = dict( slope_totals={ "face_dressing_fill": 1000.0, "face_dressing_cut": 500.0, "face_dressing_cut_soil": 300.0, "face_dressing_cut_rock": 200.0, }, rock_classes=["토사", "연암", "경암"], rock_ratios_pct={"연암": 50.0, "경암": 50.0}, application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0}, ) base.update(extra) return SummaryInput(**base) def _faces(source: SummaryInput) -> list[tuple[str, str, float, bool]]: return [ (row.spec, row.item, round(row.amount, 6), row.in_bill) for row in build_rows(source) if row.group == "면고르기" ] def test_면고르기는_파종_면적을_따라가고_절토면은_토사_암_갈래로() -> None: """성토 1000 × 파종 50% = 500 · 절토 500 × 100% → 토사 300 · 암 200 을 구성비로 연암·경암.""" assert _faces(_source()) == [ ("성토면", "", 500.0, True), ("절토면", "토사", 300.0, True), ("절토면 · 연암", "연암", 100.0, True), ("절토면 · 경암", "경암", 100.0, True), ] fill = next(r for r in build_table(_source())["rows"] if r["spec"] == "성토면") assert fill["amount_gross"] == 1000.0 and fill["application_ratio_pct"] == 50.0 assert "초류종자살포" in fill["note"] def test_덮어쓴_면적이_이기고_비고에_파종_면적과_나란히() -> None: source = _source(face_dressing_area_m2={"fill": 123.0, "cut": 100.0}) faces = _faces(source) assert faces[0] == ("성토면", "", 123.0, True) assert faces[1] == ("절토면", "토사", 60.0, True) # 100 × 300/500 fill = next(r for r in build_rows(source) if r.group == "면고르기") assert "123" in fill.note and "500" in fill.note def test_파종_면적이_0이면_면고르기도_0이고_내역에_안_섬() -> None: source = _source(application_ratios={"seed_spray_fill": 0.0, "seed_spray_cut": 0.0}) faces = _faces(source) assert faces[0] == ("성토면", "", 0.0, False) rows = [r for r in build_rows(source) if r.group == "면고르기"] assert all("파종 면적이 0" in r.note for r in rows) # ── 인계 — 코드 · 갈래 · 칸 비면 사유 ───────────────────────────────── def _handoff(**extra) -> dict: summary = build_table(_source()) return build_handoff( summary_table=summary, ground_classes=["토사", "연암", "경암"], ground_methods={"연암": "ripping", "경암": "blasting"}, **extra, ) def _items(handoff: dict) -> list[dict]: return [row for row in handoff["work_items"] if row["name"] == "면고르기"] def test_면고르기_인계는_성토면_절토면_토사_암을_각_절로() -> None: items = _items(_handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=FILL_CLAY)) got = [(r["spec"], r["ground_class"], r["work_item_code"], r["variant_value"]) for r in items] assert got == [ ("성토면", None, "FP-09-19-01", FILL_CLAY), ("절토면", "토사", "FP-09-19-01", CUT_SAND), ("절토면 · 연암", "연암", "FP-09-19-02", None), ("절토면 · 경암", "경암", "FP-09-19-03", None), ] assert not any(r["blocked_kind"] for r in items) def test_토질_시공_칸이_비면_금액_없이_입력_사유() -> None: items = _items(_handoff()) assert [r["spec"] for r in items[:2]] == ["성토면", "절토면"] # 줄이 없으면 헛통과 막음 for row in items[:2]: assert row["blocked_kind"] == "input_missing", row assert "산출 조건" in row["blocked_reason"] def test_고르는_갈래는_원문_표_두_벌과_마스터_갈래_키가_같음() -> None: """B08 칸의 선택지와 B09 표 읽기가 세운 갈래 — 두 벌이 갈리면 빨강.""" from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key node = next( n for n in load_work_item_master()["work_items"] if n["work_item_code"] == "FP-09-19-01" ) keys = {normalize_variant_key(k) for k in node["variant_keys"]} choices = { normalize_variant_key(k) for k in FACE_DRESSING_CUT_CLASSES + FACE_DRESSING_FILL_CLASSES } assert keys == choices # ── 내역 — 갈래가 서면 금액 · 기계는 사유 ───────────────────────────── def test_내역은_갈래가_서면_금액이_서고_성토면_기계는_무한궤도_제안() -> None: """9-19-2(연암 · 리핑) · 9-19-1(성토면 인력 점토 500㎡ × 3,269원) 금액이 섬. ⓘ 9-19-1 은 첫 커밋(ee9368ec) 때 공기압축기 3.5 손료 미확보(공종 단위 「일부만」)에 막혀 있었고 운전경비 표 괄호 딸림값 파서 고침으로 풀림(같은 날 · `test_b09_operating_paren_spec`). 성토면·기계는 굴착기 형식 두 갈래 · 제안 무한궤도(영월 실무 「06M3 B/H」 · 브레인 ②).""" from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill handoff = _handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=FILL_CLAY) rows = [r for r in build_bill(handoff).rows if str(r.code).startswith("FP-09-19-0")] ripping = next(r for r in rows if r.code == "FP-09-19-02") assert ripping.amount_krw and int(ripping.amount_krw) > 0, ripping.note fill = next(r for r in rows if r.code == "FP-09-19-01" and r.spec == FILL_CLAY) assert fill.amount_krw == 500 * 3269, fill.note track = "성토면 · 기계 · 무한궤도" machine = _handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=track) row = next(r for r in build_bill(machine).rows if r.code == "FP-09-19-01" and r.spec == track) assert row.amount_krw and int(row.amount_krw) > 0, row.note from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import FACE_DRESSING_FILL_SUGGESTED value, basis = FACE_DRESSING_FILL_SUGGESTED assert value == track and "06M3 B/H" in basis and "타이어" in basis