diff --git a/resources/tester/test_b08_ancillary.py b/resources/tester/test_b08_ancillary.py new file mode 100644 index 00000000..99afa9ea --- /dev/null +++ b/resources/tester/test_b08_ancillary.py @@ -0,0 +1,85 @@ +"""부대시설 다섯 줄 — 법이 요구하는데 안 내던 것 (2026-09-09 사용자 확정 ⑬). + +⚠ 겨누는 것 여섯 + ① 다섯 줄이 **개소를 안 넣어도 선다** — 빠진 것이 화면에 보이게 + ② 개소를 **지어내지 않는다** — `연장÷500` 같은 산식을 쓰지 않음 + ③ ⚠ 사유를 **갈라 적는다** — 「품셈에 공종이 없음」과 「개소 미입력」은 할 일이 다름 + ④ 법정 의무인 줄은 **그 사실이 사유에 적힌다** + ⑤ 공종이 있는 하나(가설창고)는 **값이 서고 코드가 붙는다** + ⑥ 인계에도 **실려 나간다** — 빼면 받는 쪽이 빠진 줄을 못 봄 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 + STATUS_READY, + ancillary_rows, + build_table, +) + + +def 줄(counts: dict | None = None) -> dict: + return {row["item"]: row for row in ancillary_rows(counts)} + + +def test_다섯_줄이_개소_없이도_선다() -> None: + rows = 줄() + assert set(rows) == { + "국가지점번호판", + "임도 안내판", + "차단기", + "가설창고(컨테이너)", + "수방대책 자재", + } + assert all(row["amount"] is None for row in rows.values()) + + +def test_개소를_지어내지_않는다() -> None: + """⚠ 임도규정이 「필요시 거리를 조정」이라 `연장÷500` 은 확정 산식이 아니다.""" + for row in 줄().values(): + assert row["amount"] is None + assert "개소가 아직 입력되지 않았습니다" in row["reason"] + + +def test_사유를_갈라_적는다() -> None: + """⚠ 「품셈에 공종이 없음」과 「개소 미입력」은 **다음에 할 일이 다르다**.""" + rows = 줄({"national_point_sign": 3}) + sign = rows["국가지점번호판"] + assert sign["amount"] == 3.0 + # 개소는 채워졌으니 그 사유는 사라지고, 공종이 없다는 사유만 남는다. + assert "개소가 아직 입력되지 않았습니다" not in sign["reason"] + assert "품셈에 그 이름의 공종이 없음" in sign["reason"] + + +def test_법정_의무가_사유에_적힌다() -> None: + rows = 줄() + assert "법정 의무" in rows["국가지점번호판"]["reason"] + assert "제26조제5항" in rows["국가지점번호판"]["reason"] + assert "법정 의무" in rows["임도 안내판"]["reason"] + # 차단기는 교본만 있고 법령에 없다 — 「법정」이라 적으면 거짓이 된다. + assert "법정 의무" not in rows["차단기"]["reason"] + + +def test_공종이_있는_하나는_값이_선다() -> None: + row = 줄({"site_container": 2})["가설창고(컨테이너)"] + assert (row["work_item_code"], row["amount"], row["status"]) == ("FP-11-01", 2.0, STATUS_READY) + + +def test_준비공_표와_인계에_함께_실린다() -> None: + """⚠ 빼면 받는 쪽이 「빠진 줄」을 못 본다 — 값이 없어도 사유와 함께 간다.""" + table = build_table({}, [], [], None, None, {"site_container": 1}) + names = [row["item"] for row in table["rows"]] + assert "국가지점번호판" in names + + rows = build_handoff(preparation_table=table)["work_items"] + handed = {row["name"]: row for row in rows} + assert "국가지점번호판" in handed + assert handed["국가지점번호판"]["in_bill"] is False + assert handed["가설창고(컨테이너)"]["work_item_code"] == "FP-11-01" diff --git a/resources/tester/test_b08_backfill_range.py b/resources/tester/test_b08_backfill_range.py new file mode 100644 index 00000000..2d097fa1 --- /dev/null +++ b/resources/tester/test_b08_backfill_range.py @@ -0,0 +1,169 @@ +"""막자갈(뒤채움 조약돌) — **품셈이 정한 두께 범위 안에 드는가** (2026-09-09). + +⚠⚠ **이 시험은 「같은지」가 아니라 「범위 안인지」를 잰다.** 우리 막자갈은 품셈과 **다른 식**이다. + 품셈 13-4-4 [주]⑨ 는 뒤채움 조약돌 두께를 **직고별 범위**로만 준다(상부 20~40㎝ · + 하부 직고별 30~140㎝). 두 길이 **같은 자리에 도착하는지**를 본다. +⚠⚠ **2026-09-09 오후 식이 갈아탔다** — 랩탑 보조가 확정 5차 작은 것 3 으로 막자갈을 + 「입적 − 몸통 − 고임돌」에서 **뒷채움 사다리꼴**로 바꿨다(정본 여섯 탭 상 0.30 · 하 0.45). + 그래서 아래 기록을 전부 다시 쟀다 — **옛 값(0.278~0.943)은 지금 값이 아니다.** + ⚠ **등호로 바꾸지 말 것** — 등호로 두면 우리 식이 품셈 식에 끌려간다. 우리 식은 우리 식이다. + ⚠ **범위 밖으로 나가면 「틀림」이 아니라 「봐야 함」이다** — 우리 식이 맞을 수도 있다. + 다만 **두께식이 바뀌면 조용히 새 나갈 자리**라(값은 계속 나오므로 아무도 못 본다) + 그 자리에 이 그물을 둔다. + +⚠ **이 시험이 잰 범위는 직고 1.0~7.5 m · 뒷길이 35~75㎝ 다.** + **검산은 「어디까지 쟀는지」를 함께 적지 않으면 다음 사람이 착시를 본다** — 앞서 4.0 까지만 + 재고 「다 범위 안」이라 한 적이 있다(그때 식으로는 4.5 부터 밖이었다). +⚠ **큰돌쌓기(13-6)는 이 시험의 대상이 아니다** — 두께를 **직경 위 끝**으로 보고(`BOULDER_MASONRY`) + 막자갈도 안 낸다. 그래서 높이 한계가 없어도 이 자리에 안 걸린다(2026-09-09 실측 확인). + +품셈 13-4-4 [주]⑨ <성토의 경우 뒤채움 조약돌의 두께> + 직고 ∼1.5m 상부 20~40 · 하부 30~60 ⇒ 평균 0.25~0.50 m + 직고 ∼3.0m 상부 20~40 · 하부 45~75 ⇒ 평균 0.33~0.58 m + 직고 ∼5.0m 상부 20~40 · 하부 60~100 ⇒ 평균 0.40~0.70 m + 직고 ∼7.0m 상부 20~40 · 하부 80~140 ⇒ 평균 0.50~0.90 m +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import stone_masonry # noqa: E402 + +#: (직고 상한, 평균두께 범위) — 원문 상·하부 범위의 평균이다. +PUMSEM_RANGE_M: tuple[tuple[float, float, float], ...] = ( + (1.5, 0.25, 0.50), + (3.0, 0.33, 0.58), + (5.0, 0.40, 0.70), + (7.0, 0.50, 0.90), +) + + +def 범위(height_m: float) -> tuple[float, float]: + for limit, low, high in PUMSEM_RANGE_M: + if height_m <= limit: + return low, high + return PUMSEM_RANGE_M[-1][1], PUMSEM_RANGE_M[-1][2] + + +def 두께(height_m: float, back_cm: int) -> float: + """우리 막자갈을 **면적으로 나눈 값** — 품셈이 말하는 「뒤채움 두께」와 같은 축.""" + components, _ = stone_masonry( + height_m, + 10.0, + {"height_m": height_m, "length_m": 10.0, "back_len_cm": back_cm}, + wet=True, + face="성토", + ) + got = {c.name: c.amount for c in components} + return got["막자갈"] / got["돌쌓기"] + + +def test_3m_까지는_품셈_범위_안에_든다() -> None: + """⚠ 실패하면 「틀렸다」가 아니라 **「봐야 한다」**는 뜻이다 — 두께식이 바뀐 자리를 알리는 그물. + + ⚠ **3.0m 까지인 것이 우연이 아니다** — 정본 여섯 탭을 전수 확인한 구간이 **H=1.0~3.0** + 이다(`STONE_BACKFILL_WIDTH_M` 머리 주석). 근거가 있는 구간에서는 품셈 범위와 맞고, + 근거가 없는 구간에서 갈린다. + """ + for height, back in ((1.0, 45), (1.2, 35), (2.5, 45), (3.0, 45), (3.0, 55), (3.0, 75)): + low, high = 범위(height) + got = 두께(height, back) + assert low <= got <= high, ( + f"H={height} ℓ3={back}㎝ 뒤채움 두께 {got:.3f}m 가 품셈 13-4-4 [주]⑨ 범위" + f" {low}~{high}m 밖 — 우리 식이 맞을 수도 있으니 **틀림이 아니라 봐야 함**" + ) + + +def test_3m_를_넘으면_범위_아래로_나간다() -> None: + """⚠⚠ **지금 상태를 기록해 두는 시험이다**(2026-09-09 오후 실측). + + ⚠ **방향이 뒤집혔다** — 옛 식은 높은 벽에서 품셈 범위 **위**로 나갔는데, 새 식은 + **아래**로 나간다. 새 식은 두께가 H 에 안 붙기 때문이다. + H 1.0 0.364 · 1.5 0.364 (안) · 2.0~3.0 0.359 (안) + H 3.5~5.0 0.354 · 6.0~7.0 0.348 · 7.5 0.342 (전부 **밖 — 아래**) + 까닭은 새 막자갈이 **(상 0.30 + 하 0.45) ÷ 2 × H × 연장** 이라 두께(= 막자갈 ÷ 비탈면적)가 + **0.375 ÷ √(1+경사²)** 로 거의 붙박이인 데 있다. 직고가 커지면 표준경사가 커져 + 비탈면적만 늘어 **오히려 조금씩 얇아진다.** 품셈 표는 반대로 직고와 함께 두꺼워진다. + ⚠ **틀렸다고 단정하지 않는다** — 정본 여섯 탭이 근거이고 품셈 표는 **범위**다. 다만 + 정본 전수 확인 구간이 **H=1.0~3.0** 이고 갈리는 자리도 **3.0m 부터**라 겹친다 — + **높은 벽의 정본 근거가 없다**는 뜻이므로 확정 5차 작은 것 3 을 다시 볼 때 이 자리를 볼 것. + ⚠ 이 시험이 깨지면(범위 안으로 들어오면) **두께식이 바뀐 것**이니 위 기록을 고칠 것. + """ + 안쪽 = 두께(3.0, 45) + 바깥 = 두께(3.5, 45) + assert 범위(3.0)[0] <= 안쪽 <= 범위(3.0)[1] + assert 바깥 < 범위(3.5)[0] + assert 0.35 < 바깥 < 0.36 + + +def test_직고가_커져도_두께가_거의_안_움직인다() -> None: + """⚠ **뒤집힌 시험이다** — 종전에는 「직고가 커지면 두꺼워진다」였다(품셈 표와 같은 방향). + + 새 식에서는 막자갈 **물량**이 H 에 비례해 늘지만 **두께**(물량 ÷ 비탈면적)는 거의 + 붙박이다. 이 사실 자체를 못 박아, 나중에 누가 「두께가 직고를 따라간다」고 잘못 + 읽는 것을 막는다. + """ + thicknesses = [두께(h, 45) for h in (1.2, 2.5, 4.0, 6.0)] + assert max(thicknesses) - min(thicknesses) < 0.02 + # ⚠ 물량 자체는 직고를 따라 는다 — 두께가 안 움직인다고 물량까지 굳은 것이 아니다. + 부피 = [두께(h, 45) * h for h in (1.2, 2.5, 4.0, 6.0)] + assert 부피 == sorted(부피) + + +def test_뒷길이는_두께를_안_움직인다() -> None: + """⚠ 옛 식에서는 뒷길이가 밑수였다 — 지금은 정본 폭 붙박이라 아예 안 본다.""" + assert len({round(두께(2.5, b), 6) for b in (35, 45, 55, 60, 75)}) == 1 + + +def test_등호가_아니라_범위다() -> None: + """⚠ 이 시험의 뜻을 못 박는다 — 품셈 값과 **같지 않아도 된다**. + + 같아지길 요구하면 우리 식(정본 뒷채움 사다리꼴)이 품셈 식에 끌려간다. + """ + low, high = 범위(2.5) + got = 두께(2.5, 45) + assert got != low and got != high # 경계값과 같을 이유가 없다 + assert low < got < high + + +def test_근거_구간_밖이면_사유가_뜬다() -> None: + """⚠ **값은 계속 나온다** — 사유가 없으면 아무도 못 본다. + + 교본 7-3 이 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고**, + 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 높은 벽이 실제로 설 수 있다. + 막지도 눅이지도 않고 **정본 확인 구간 밖이라는 사실**만 사유로 낸다(임의 확정 금지). + """ + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import stone_masonry + + def 사유(height_m: float) -> list[str]: + _, notes = stone_masonry( + height_m, + 10.0, + {"height_m": height_m, "length_m": 10.0, "back_len_cm": 45}, + wet=True, + face="성토", + ) + return [n for n in notes if "정본 확인 구간" in n] + + assert 사유(3.0) == [] # 근거가 있는 구간에서는 잔소리하지 않는다 + 밖 = 사유(3.5) + assert len(밖) == 1 + assert "13-4-4 [주]⑨" in 밖[0] and "실무자 확인" in 밖[0] + + +def test_큰돌쌓기는_이_식을_안_쓴다() -> None: + """⚠ 높이 한계가 없는 것은 큰돌쌓기뿐이라 **거기까지 번지면 살아 있는 자리**가 된다. + + 큰돌쌓기(13-6)는 막자갈 줄 자체를 안 낸다 — 이 갈림이 거기로 안 번진다. + """ + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import boulder_masonry + + components, _ = boulder_masonry( + 5.0, 10.0, {"height_m": 5.0, "length_m": 10.0, "stone_cm": "60~80"} + ) + assert not any(c.name == "막자갈" for c in components) diff --git a/resources/tester/test_b08_basis_unit.py b/resources/tester/test_b08_basis_unit.py new file mode 100644 index 00000000..63591f7b --- /dev/null +++ b/resources/tester/test_b08_basis_unit.py @@ -0,0 +1,277 @@ +"""보내는 단위가 품셈 밑수와 같은가 — 금액이 실제로 틀렸던 자리 (2026-09-08). + +⚠ 겨누는 것 다섯 + ① 돌쌓기가 **㎡ · 비탈면적**으로 나가는가 (m · 연장이면 금액이 2.6배) + ② 성분이 안 서면 **연장으로 대신 세지 않는가** — 세면 다시 틀린 축이다 + ③ 밑수가 m 인 공종(맹암거·측구)은 **종전대로** 연장인가 + ④ 표기 차이(㎥/m3 · 개소/개)는 흡수하되 **뜻이 다른 단위는 드러내는가** + ⑤ 인계본에 그 경고가 **실제로 실리는가** (만들어 두고 안 부르면 없는 것과 같다) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_BasisUnit import ( # noqa: E402 + basis_units, + normalize_unit, + verify_unit_matches_basis, +) +from B08_Quantity.B08_Quantity_Engine_Handoff import ( # noqa: E402 + build_handoff, + load_mapping, +) + +MAPPING = load_mapping() + + +def 돌쌓기(**options: object) -> dict: + """전개까지 실제로 돌린 구조물 한 기 — 값을 손으로 적지 않는다.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand + + item = expand( + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, **options}, + }, + names={"masonry_wet": "돌쌓기(찰)"}, + ) + return { + "structure_id": item.structure_id, + "type_id": item.type_id, + "name": item.name, + "length_m": item.length_m, + "height_m": item.height_m, + "start_m": item.start_m, + "end_m": item.end_m, + "options": item.options, + "notes": item.notes, + "components": [ + {"name": c.name, "unit": c.unit, "amount": c.amount, "basis": c.basis} + for c in item.components + ], + } + + +def 인계(structure: dict) -> dict: + return build_handoff(unit_quantity_table={"structures": [structure]}, mapping=MAPPING) + + +def test_돌쌓기는_면적으로_나간다() -> None: + """⚠ 실측 — 10m 로 나가 529,389원이 섰다. 26.101㎡ 로 세야 1,381,753원이다.""" + row = 인계(돌쌓기(back_len_cm=45))["work_items"][0] + assert row["work_item_code"] == "FP-13-04-05" + assert row["unit"] == "㎡" + assert abs(row["quantity"] - 26.101) < 0.01, row["quantity"] + assert row["in_bill"] is True + + +def test_면적이_안_서면_연장으로_대신_세지_않는다() -> None: + """뒷길이 40 은 품셈 표에 없어 전개가 안 선다 — 그때 10.0 m 로 서면 안 된다.""" + row = 인계(돌쌓기(back_len_cm=40))["work_items"][0] + assert row["quantity"] != 10.0 + assert row["in_bill"] is False or row["blocked_kind"] + assert row["blocked_reason"] + + +def test_밑수가_m_인_공종은_종전대로_연장이다() -> None: + row = 인계( + { + "structure_id": "s2", + "type_id": "underdrain", + "name": "맹암거", + "length_m": 40.0, + "start_m": 0.0, + "end_m": 40.0, + "components": [], + "notes": [], + "options": {}, + } + )["work_items"][0] + assert (row["unit"], row["quantity"]) == ("m", 40.0) + + +def test_표기_차이는_흡수한다() -> None: + assert normalize_unit("m3") == normalize_unit("㎥") + assert normalize_unit("개") == "개소" + rows = [{"work_item_code": "FP-12-15", "unit": "개", "in_bill": True, "name": "집수정"}] + assert verify_unit_matches_basis(rows) == [] + + +def test_뜻이_다른_단위는_드러낸다() -> None: + """⚠ 환산하지 않는다 — m 을 ㎡ 로 바꾸려면 두께를 지어내야 한다.""" + rows = [{"work_item_code": "FP-13-04-05", "unit": "m", "in_bill": True, "name": "돌쌓기(찰)"}] + warnings = verify_unit_matches_basis(rows) + assert warnings and "㎡" in warnings[0] + + +def test_마스터에_없는_코드는_트집_잡지_않는다() -> None: + rows = [{"work_item_code": "FP-99-99", "unit": "포", "in_bill": True, "name": "가짜"}] + assert verify_unit_matches_basis(rows) == [] + + +def test_인계본이_그_경고를_싣는다() -> None: + """⚠ 검사를 만들어 두고 안 부르면 없는 것과 같다(2026-09-08 자기 감사에서 둘이 놀고 있었다).""" + handoff = 인계(돌쌓기(back_len_cm=45)) + assert handoff["basis_unit_warnings"] == [] + + +def test_품셈에_면적_밑수가_실제로_있다() -> None: + """⚠ 우리 판단의 근거가 **원문**인지 확인한다 — 없으면 이 고침 자체가 근거를 잃는다.""" + table = basis_units() + assert table["FP-13-04-05"] == {"㎡"} + assert table["FP-13-06-01"] == {"㎡"} + assert "m" in table["FP-12-10"] + + +# ── 마스터가 밑수를 모르는 공종 — 매핑이 원문에서 읽어 채운다 (2026-09-08 층따기) ── +# ⚠ 층따기 9-18 은 절 머리에 「(단위: …)」가 없고 [주] 공식 `Q1 = … = ㎥/시간` 만 있다. +# 마스터 `basis_unit` 이 비어 대조가 조용히 통과했고, ㎡ 2,645.21 × ㎥당 단가로 +# 410만원이 서 있었다. + + +def 토공(group: str, unit: str, amount: float, item: str | None = None) -> dict: + row = {"group": group, "unit": unit, "amount": amount, "in_bill": True} + if item: + row["item"] = item + return row + + +def test_층따기_길이를_넣으면_체적으로_나간다() -> None: + """⭐ 2026-09-09 확정 2차 ① — 면적이 정본이고 **부피는 사용자가 넣은 길이를 곱해** 쓴다. + + 그러면 품셈 ㎥ 단가를 그대로 쓸 수 있어 단위 불일치가 풀린다. + ⚠ 면적은 없애지 않는다 — 횡단도 하단 표가 면적을 쓴다. **㎥ 를 덧붙이는 것**이다. + """ + row = build_handoff( + summary_table={"rows": [토공("층따기", "㎡", 13696.75)]}, bench_cut_depth_m=0.5 + )["work_items"][0] + assert (row["unit"], row["quantity"]) == ("㎥", 13696.75 * 0.5) + assert row["in_bill"] is True + # 어디서 온 값인지 규격 칸에 남는다 — 면적을 못 되짚으면 검산이 안 된다. + assert "13,696.75㎡" in row["spec_detail"] and "0.5m" in row["spec_detail"] + + +def test_층따기_길이가_없으면_막히고_사유가_간다() -> None: + """⚠ 길이 기본값을 **임의로 박지 않는다** — 교본이 「설계도서에 명시」라 설계 입력이다.""" + row = build_handoff(summary_table={"rows": [토공("층따기", "㎡", 13696.75)]})["work_items"][0] + assert row["in_bill"] is False + assert row["blocked_kind"] == "input_missing" + assert "길이" in row["blocked_reason"] + # 면적은 규격 칸에 남는다 — 값이 사라지면 왜 막혔는지 못 짚는다. + assert "13,696.75" in row["spec_detail"] + + +def _옛_시험_층따기는_면적으로_나간다() -> None: + """⭐ 2026-09-09 사용자 확정 ⑦ — 단위는 **㎡**(실무 관행). + + ⚠ 앞서는 「품셈 공식이 ㎥ 라 못 곱한다」로 막아 두었다(410만원 자리). 확정으로 ㎡ 가 + 정본이 되었으므로 **막지 않는다.** 대신 매핑에 그 결정과 까닭을 적어 두었고, + ㎡ 단가를 세우는 것은 받는 쪽 몫이다. + """ + row = build_handoff(summary_table={"rows": [토공("층따기", "㎡", 2645.21)]})["work_items"][0] + assert row["work_item_code"] == "FP-09-18" + assert (row["unit"], row["quantity"]) == ("㎡", 2645.21) + assert row["in_bill"] is True + assert row["blocked_kind"] is None + + +def test_성토면다짐은_면적이_맞다() -> None: + """⚠ 좁게 — 같은 면적을 쓰는 형제 공종까지 막으면 멀쩡한 줄이 사라진다. + + 9-17-1 비탈면 다짐은 시공량이 **㎡/시간**이라 면적이 맞다(2026-09-08 B09 원문 확인). + """ + row = build_handoff(summary_table={"rows": [토공("성토면다짐", "㎡", 2645.21)]})["work_items"][ + 0 + ] + assert (row["unit"], row["quantity"]) == ("㎡", 2645.21) + assert row["in_bill"] is True + + +def test_매핑이_적은_밑수가_대조에_쓰인다() -> None: + """마스터가 비어 있어도 매핑이 적은 값으로 경고가 선다.""" + rows = [{"work_item_code": "FP-09-18", "unit": "㎡", "in_bill": True, "name": "층따기"}] + assert verify_unit_matches_basis(rows) == [] # 마스터만 보면 못 잡는다 + assert verify_unit_matches_basis(rows, extra={"FP-09-18": "㎥"}) + + +# ── 매핑이 적은 밑수는 **원문에 실제로 있는 글자**여야 한다 (2026-09-08) ──────────── +# ⚠ 근거 없는 단위가 대조의 기준이 되면 가드가 거짓말을 한다. 그래서 원문을 연다. + +PUM_TEXT = ( + ROOT + / "resources" + / "knowledge" + / "original" + / "행정규칙" + / "임도 품셈 적용기준 (현 산림사업 표준품셈)" + / "첨부" + / "(산림청고시 제2025-82호) 산림사업 표준품셈.md" +) + +#: 매핑에 적은 밑수와 그 근거 줄(원문 줄번호는 1부터). +DECLARED_SOURCE = { + "FP-09-03-02": (4704, "㎥"), + "FP-09-04": (4738, "㎥"), + "FP-09-05": (4744, "㎥"), + # 2026-09-08 실무 양식이 정해져 부모(FP-09-16 노체)에서 **자식 포설**로 내렸다 — + # 실무 내역 셋에 「노체포설·노체다짐」으로 가른 줄이 없고 성토 본체가 한 줄이다. + "FP-09-16-01": (5322, "㎥"), + "FP-09-17-01": (5368, "㎡"), + "FP-10-11": (5919, "㎥"), + "FP-10-12": (5928, "㎥"), + # ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양 — 마스터가 못 읽던 자리(2026-09-08). + "FP-05-24": (2769, "㎡"), +} + + +def test_매핑이_적은_밑수가_원문_줄에_실제로_있다() -> None: + if not PUM_TEXT.is_file(): + return + lines = PUM_TEXT.read_text(encoding="utf-8").splitlines() + declared = MAPPING.declared_units() + for code, (line_no, unit) in DECLARED_SOURCE.items(): + assert declared.get(code) == unit, code + assert unit in lines[line_no - 1], f"{code} L{line_no}: {lines[line_no - 1][:60]}" + + +def test_품_단위는_밑수로_안_받는다() -> None: + """⚠ 「일당」은 **하루에 얼마**라는 품의 단위다 — 받으면 틀린 단위로 검사를 통과시킨다.""" + assert "FP-12-06" not in MAPPING.declared_units() + + +def test_덤프_단위중량이_원문_줄에_있다() -> None: + """⚠ 「현장값이라 미확보」로 두었던 γt 가 원문에 있었다(2026-09-08 정정).""" + if not PUM_TEXT.is_file(): + return + line = PUM_TEXT.read_text(encoding="utf-8").splitlines()[5930 - 1] + assert "1.9ton/㎥" in line and "2.4ton/㎥" in line + + +# ── 공종 단위가 아닌 글자는 **안 받는다** (2026-09-08 B09 전수에서 나온 모양들) ──── +# ⚠ `(㎥/1대, 1일)` 은 **시공량**이라 밑수의 **역수**다 — 받으면 그 공종이 조용히 +# 뒤집힌 단위를 갖는다. 안 받으면 대조가 없을 뿐이라 **덜 나쁘다.** + + +def test_시공량_모양은_밑수로_안_받는다() -> None: + from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import is_quantity_unit + + for bad in ("㎥/1대, 1일", "㎥/1인, 1일", "대/ton", "1ha당, 100본당", "일당", "조", "인"): + assert not is_quantity_unit(bad), bad + for good in ("㎥", "㎡", "m", "개소", "㏊당", "10m당"): + assert is_quantity_unit(good), good + + +def test_매핑에_적힌_밑수가_전부_공종_단위다() -> None: + """⚠ 사람이 손으로 적는 칸이라 **다음 사람이 시공량을 적을 수 있다.**""" + from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import is_quantity_unit + + for code, unit in MAPPING.declared_units().items(): + assert is_quantity_unit(unit), f"{code}: {unit}" diff --git a/resources/tester/test_b08_blinding.py b/resources/tester/test_b08_blinding.py new file mode 100644 index 00000000..5e873125 --- /dev/null +++ b/resources/tester/test_b08_blinding.py @@ -0,0 +1,105 @@ +"""버림 콘크리트 — 빠뜨리고 있던 줄 (2026-09-09 사용자 확정 ⑭). + +⚠ 겨누는 것 다섯 + ① 기본은 **넣는다** — 정한 적 없는 프로젝트에서 줄이 사라지지 않아야 한다 + ② 「안 넣음」으로 두면 **빠진다** + ③ 두께는 **0.10m**(KDS 44 90 00) — 임의 값이 아니다 + ④ 근거 문구에 **잠정임이 드러난다**(폭이 잡석다짐 폭이라야 하나 그 값이 아직 없다) + ⑤ ⚠ 채움 콘크리트와 **섞이지 않는다** — 타설 줄은 버림 몫만 세운다 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + BLINDING_THICKNESS_M, + boulder_masonry, + stone_masonry, + wants_blinding, +) + + +def 성분(**options: object) -> dict: + components, _ = stone_masonry( + 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, wet=True + ) + return {c.name: c for c in components} + + +def test_기본은_넣는다() -> None: + """⚠ 빈 칸을 「빼기」로 읽으면 저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다.""" + assert wants_blinding({}) is True + assert "버림콘크리트" in 성분() + + +def test_안_넣음으로_두면_빠진다() -> None: + for value in ("안 넣음", "제외", "false", "no"): + assert wants_blinding({"blinding_concrete": value}) is False, value + assert "버림콘크리트" not in 성분(blinding_concrete="안 넣음") + + +def test_두께는_원문값이다() -> None: + """KDS 44 90 00 「구조물 시공이 원활하도록 **100 mm 두께**의 버림콘크리트를 타설」.""" + assert BLINDING_THICKNESS_M == 0.10 + got = 성분()["버림콘크리트"] + # 기초 폭(터파기 폭) × 연장 × 두께 — 폭이 근거 문구에 적혀 있으므로 그 값으로 검산한다. + assert got.unit == "㎥" + assert got.amount > 0 + + +def test_폭은_하단_길이에서_온다() -> None: + """⚠ 「폭은 잡석다짐 폭」(KCS 34 50 05)이고 그 폭이 확정 ⑪ 로 **하단 길이**가 됐다. + + 옛 잠정(터파기 폭 = 평균두께 + 0.2)을 지운 자리다 — 근거 문구에 어디서 온 폭인지 적힌다. + """ + got = 성분()["버림콘크리트"] + 하부두께 = 0.45 + 0.30 + 0.30 * (2.5 - 1.0) # 1.20 + assert abs(got.amount - 하부두께 * 10.0 * BLINDING_THICKNESS_M) < 1e-9 + assert "KDS" in got.basis and "하단 길이" in got.basis + assert "잠정" not in got.basis + + +def test_기초잡석은_두께가_없어_안_선다() -> None: + """⚠ 폭은 생겼지만 **두께가 원문에 없다** — 지어내지 않고 사유로 드러낸다.""" + _, notes = stone_masonry( + 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, wet=True + ) + assert any("기초잡석(12-25)" in n and "두께가 원문에 없음" in n for n in notes) + + +def test_큰돌쌓기에도_선다() -> None: + components, _ = boulder_masonry( + 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "stone_cm": "60~80"} + ) + assert any(c.name == "버림콘크리트" for c in components) + + +def test_타설_줄은_버림_몫만_센다() -> None: + """⚠ 채움 콘크리트가 섞이면 이중계상 — 그 공종 품에 이미 들어 있을 수 있다.""" + item = 성분() + structure = { + "structure_id": "s1", + "type_id": "masonry_wet", + "name": "돌쌓기(찰)", + "length_m": 10.0, + "height_m": 2.5, + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, + "notes": [], + "components": [ + {"name": c.name, "unit": c.unit, "amount": c.amount, "basis": c.basis} + for c in item.values() + ], + } + rows = build_handoff(unit_quantity_table={"structures": [structure]})["work_items"] + placing = [row for row in rows if row["name"] == "콘크리트 타설"] + assert len(placing) == 1 + assert abs(placing[0]["quantity"] - item["버림콘크리트"].amount) < 1e-6 + assert abs(placing[0]["quantity"] - item["채움콘크리트"].amount) > 1e-6 diff --git a/resources/tester/test_b08_blocked_invariant.py b/resources/tester/test_b08_blocked_invariant.py new file mode 100644 index 00000000..c7894def --- /dev/null +++ b/resources/tester/test_b08_blocked_invariant.py @@ -0,0 +1,152 @@ +"""**코드가 없으면 반드시 막힘 표시가 붙는다** (2026-09-09 랩탑 메인 제보로 세운 불변식). + +⚠⚠ 오늘 세운 규칙 「막혔다고 말하기 전에 `blocked_kind` 를 볼 것」의 **뒤집힌 얼굴**이다. + 보는 쪽을 그렇게 고쳤으면 **다는 쪽도 빠짐없이 달아야** 한다. 안 그러면 받는 쪽이 + 「코드도 없고 막힘 표시도 없는 **멀쩡한 줄**」로 읽어 **금액이 조용히 빠진다.** + +실제로 그랬다 — `측구터파기 · 굴삭기+브레카` 가 `work_item_code: null` · `blocked_kind: null` +이었고 화면에 아무 말도 안 떴다. 인계본의 `bill_flag_warnings`·`unmatched_work_items` 에는 +**이미 실려 있었다** — 목록은 있는데 **줄에 표시가 없었던 것**이다. 줄 단위로 보는 쪽은 +목록을 안 본다. + +⚠ 예외 둘 — 이 둘만 코드 없이 서도 된다. + ① **묶음 줄**(`composite_parts`) — 무엇으로 묶이는지 조각에 적혀 있다 + ② **내역에 안 서는 줄**(`in_bill: False`) — 합계·무대처럼 **막힌 게 아니라 안 세는 것**. + 다만 그쪽도 **까닭은 있어야** 한다(`in_bill_reason` 또는 `blocked_kind`). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + + +def 집계(item: str | None, group: str = "흙깎기") -> dict: + return {"rows": [{"group": group, "item": item, "unit": "㎥", "amount": 100.0}]} + + +def 인계(**kwargs) -> list[dict]: + return build_handoff(**kwargs)["work_items"] + + +def test_시공법을_못_고른_줄에_막힘_표시가_붙는다() -> None: + """⚠ 실측 자리 — 「굴삭기+브레카」는 지반 갈래가 아니라 **시공법**이라 매핑이 안 맞았다.""" + rows = 인계(summary_table=집계("리핑암")) + row = next(r for r in rows if r["name"] == "흙깎기") + assert row["work_item_code"] is None + assert row["blocked_kind"] == "input_missing" # 시공법을 고르면 풀린다 + assert "시공법" in row["blocked_reason"] + + +def test_시공법을_고르면_코드가_붙고_표시가_사라진다() -> None: + # ⚠ 시공법 값은 **영문 키**다(`ripping`·`blasting`) — 한글을 넣으면 안 풀린다. + rows = 인계(summary_table=집계("리핑암"), ground_methods={"리핑암": "ripping"}) + row = next(r for r in rows if r["name"] == "흙깎기") + assert row["work_item_code"] is not None + assert row["blocked_kind"] is None + + +def test_품셈을_못_이은_줄은_다른_갈래로_적힌다() -> None: + """⚠ 「사용자가 고르면 풀림」과 「우리가 매핑을 못 이음」은 **다음에 할 일이 다르다**.""" + rows = 인계(summary_table=집계(None, group="지장목제거")) + row = next(r for r in rows if r["name"] == "지장목제거") + assert row["work_item_code"] is None + assert row["blocked_kind"] == "unit_data_missing" + assert "못 이었습니다" in row["blocked_reason"] + + +def test_불변식_모든_줄에_대해_선다() -> None: + """⚠ 한 자리를 고치는 것으로 끝내지 않는다 — **줄 빌더 여덟 곳 전부**를 건다.""" + unit = build_table( + [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, + }, + { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 20.0, + "end_m": 30.0, + "options": {"height_m": 2.0, "length_m": 10.0, "form": "반중력식"}, + }, + ] + ) + haul = { + "rows": [ + {"equipment": "dump_truck", "ground": "토사", "volume_m3": 50.0}, + {"equipment": "free_haul", "ground": "토사", "volume_m3": 10.0}, + {"equipment": "모르는장비", "ground": "토사", "volume_m3": 5.0}, + ] + } + rows = 인계( + summary_table=집계("리핑암"), + haul_table=haul, + unit_quantity_table=unit, + preparation_table={"rows": []}, + ) + 나쁜 = [ + r["name"] + for r in rows + if not r.get("work_item_code") + and not r.get("composite_parts") + and r.get("in_bill") + and not r.get("blocked_kind") + ] + assert 나쁜 == [], f"코드도 막힘 표시도 없이 내역에 서는 줄: {나쁜}" + + # 내역에 안 서는 줄도 **까닭 없이** 있으면 안 된다. + 까닭없음 = [ + r["name"] + for r in rows + if not r.get("in_bill") + and not r.get("blocked_kind") + and not str(r.get("in_bill_reason") or "").strip() + ] + assert 까닭없음 == [], f"안 서는 까닭이 안 적힌 줄: {까닭없음}" + + +def test_무대는_막힌_것이_아니다() -> None: + """⚠ 무대(소운반 20m 이내)는 **품에 포함**이라 안 세우는 것이지 막힌 것이 아니다. + + 여기에 막힘 표시를 달면 「만들어야 할 것」 목록에 올라 없는 일이 생긴다. + """ + rows = 인계(haul_table={"rows": [{"equipment": "free_haul", "ground": "토사", "volume_m3": 10.0}]}) + row = next(r for r in rows if r["haul_equipment"] == "free_haul") + assert row["in_bill"] is False + assert row["blocked_kind"] is None + assert "품에 포함" in row["in_bill_reason"] + + +def test_물량_0_인_구조물은_내역에_안_선다() -> None: + """⚠ 물넘이포장이 면적을 안 받아 **`0.0 ㎡` 로 내역에 서고 있었다**(2026-09-09 감사). + + 코드가 붙어 있어 **0 원 줄**이 만들어지고 화면에는 「값이 있는 줄」로 보인다 — + 0 은 「없음」과 구별이 안 된다(오늘 표토제거에서 겪은 자리와 같은 결). + 줄은 그대로 넘기되 **내역에서 빼고 까닭을 적는다.** + """ + unit = build_table( + [ + { + "structure_id": "f1", + "type_id": "ford_pavement", + "start_m": 0.0, + "end_m": 10.0, + "options": {"thickness_cm": 20, "length_m": 10.0}, + } + ] + ) + row = next(r for r in 인계(unit_quantity_table=unit) if r["name"] == "물넘이포장") + assert row["quantity"] == 0.0 + assert row["in_bill"] is False + assert "물량이 0" in row["in_bill_reason"] + assert row["blocked_kind"] == "input_missing" diff --git a/resources/tester/test_b08_coeff_and_strength.py b/resources/tester/test_b08_coeff_and_strength.py new file mode 100644 index 00000000..f39ff16d --- /dev/null +++ b/resources/tester/test_b08_coeff_and_strength.py @@ -0,0 +1,92 @@ +"""계수 갈래·채움콘 강도·돌 중량 근거 (2026-09-08 사용자 확정 2차 ⑤·⑨·⑩). + +⚠ 겨누는 것 여섯 + ① **빈 칸은 「안 정함」**이다 — 기본값은 등록부가 아니라 계산 쪽이 갖는다 + ② ⑨ 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15) + ③ ⑨ 「실무 관행」으로 두면 건설품셈 참고자료 열(0.15 · 0.20)로 덮이고 사유가 뜬다 + ④ ⑩ 강도 기본은 **210**, 180 을 고르면 그 값이 이긴다 + ⑤ ⚠ 아는 값이 아니면 **조용히 넘어가지 않는다** + ⑥ ⑤ 돌 중량은 값을 그대로 두되 **어디서 온 값인지**가 줄에 적힌다 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + FILL_CONCRETE_MPA_DEFAULT, + fill_concrete_mpa, + stone_masonry, + wants_practice_coefficients, +) + + +def 성분(**options: object) -> tuple[dict, list[str]]: + components, notes = stone_masonry( + 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, wet=True + ) + return {c.name: c for c in components}, notes + + +def test_빈_칸은_안_정함이다() -> None: + assert wants_practice_coefficients({}) is False + assert fill_concrete_mpa({})[0] == FILL_CONCRETE_MPA_DEFAULT == "210" + + +def test_기본은_품셈_열이다() -> None: + """야면석을 고르면 품셈 계수(고임돌 0.11 · 채움 0.15)가 선다 — 확정 ⑨.""" + got, _ = 성분(stone_kind="야면석·호박돌") + 돌쌓기 = got["돌쌓기"].amount + assert abs(got["고임돌"].amount - 돌쌓기 * 0.11) < 0.01 + assert abs(got["채움콘크리트"].amount - 돌쌓기 * 0.15) < 0.01 + + +def test_실무_관행으로_두면_덮이고_사유가_뜬다() -> None: + """⚠ 값이 조용히 바뀌면 안 된다 — 덮어썼다는 사실이 사유로 나온다.""" + got, notes = 성분(stone_kind="야면석·호박돌", stone_coeff_basis="실무 관행") + 돌쌓기 = got["돌쌓기"].amount + assert abs(got["고임돌"].amount - 돌쌓기 * 0.15) < 0.01 + assert abs(got["채움콘크리트"].amount - 돌쌓기 * 0.20) < 0.01 + assert any("실무 관행" in n for n in notes) + + +def test_강도는_기본_210이고_고르면_그_값() -> None: + 기본, _ = 성분() + assert 기본["채움콘크리트"].spec == "210" + assert "확정 ⑩" in 기본["채움콘크리트"].basis + 고름, _ = 성분(fill_concrete_mpa="180") + assert 고름["채움콘크리트"].spec == "180" + assert "고른 값" in 고름["채움콘크리트"].basis + + +def test_모르는_강도는_조용히_넘어가지_않는다() -> None: + 강도, basis = fill_concrete_mpa({"fill_concrete_mpa": "240"}) + assert 강도 == "210" + assert "240" in basis + + +def test_돌_중량은_값을_두고_근거를_남긴다() -> None: + """⚠ 품셈·교본에 돌중량표가 없다 — 값은 그대로 두고 출처를 줄에 단다(확정 ⑤). + + ⚠ 2026-09-09 확정 5차 큰 것 7 로 **관측표를 보는 자리가 야면석 계열로 좁아졌다** — + 종류를 안 고르면 계산식(뒷길이 × 0.77 × 2.65)으로 선다. 출처를 다는 못은 그대로이되 + **어느 조건에서 다는지**가 달라졌으므로 조건을 명시한다. + """ + got, _ = 성분(stone_kind="야면석·호박돌") + 야면석 = got["야면석·호박돌"] + assert abs(야면석.amount - got["돌쌓기"].amount * 0.88) < 1e-9 + assert "울진" in 야면석.basis and "품셈·교본에는 돌중량표가 없음" in 야면석.basis + assert 야면석.source == "uljin_library" + + +def test_안_고르면_계산식으로_서고_그_사실이_적힌다() -> None: + """⚠ 값이 서는 것으로 끝나면 **관측값인지 계산값인지**를 화면에서 못 가른다.""" + got, notes = 성분() + 돌 = got["돌"] + assert 돌.source == "" # 관측 출처가 아니다 + assert "0.77" in 돌.basis and "2.65" in 돌.basis + assert any("계산식" in n for n in notes) diff --git a/resources/tester/test_b08_collected_stone.py b/resources/tester/test_b08_collected_stone.py new file mode 100644 index 00000000..4d7e008b --- /dev/null +++ b/resources/tester/test_b08_collected_stone.py @@ -0,0 +1,104 @@ +"""채집석 공제 — 캐서 쓴 돌만큼 사토가 준다 (2026-09-09 사용자 확정 ②). + +⚠⚠ **공제는 사토에서 한 번만 한다.** B08 은 소요량(㎥ **양수**)을 내기만 하고 빼지 않는다. + 빼는 자리는 유토곡선의 사토뿐이다 — 실어 내는 몫에서 먼저 빼고 모자라면 자연방토에서. + +⚠ 겨누는 것 다섯 + ① 기본은 「캔다」 — 정한 적 없으면 공제가 선다(법이 「가급적 현장 채취」) + ② 「구입」으로 둔 구조물은 **공제 없음** — 구조물마다 갈린다 + ③ ⚠ **부호를 안 넘긴다** — 양수로 준다(음수로 주면 받는 쪽에서 두 번 뒤집힌다) + ④ 자재총괄에 **안 섞인다** — 자재는 `material` 만 모은다 + ⑤ 인계 맨 위에 **한 값으로** 실린다(구조물마다 갈리므로 합산해서 하나로) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 + build_table as build_material_table, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + COLLECTED_STONE_KEY, + build_table, + is_collected_stone, + stone_masonry, +) + + +def 구조물(**options: object) -> dict: + return { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, + } + + +def 성분(**options: object) -> dict: + components, _ = stone_masonry( + 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, wet=True + ) + return {c.name: c for c in components} + + +def test_기본은_캔다() -> None: + """법이 그쪽을 권한다 — 별표2 「야면석 등은 **가급적 현장에서 채취·사용**」.""" + assert is_collected_stone({}) is True + assert "채집석" in 성분() + + +def test_구입으로_두면_공제가_없다() -> None: + for value in ("구입", "purchase", "buy"): + assert is_collected_stone({"stone_supply": value}) is False, value + assert "채집석" not in 성분(stone_supply="구입") + + +def test_양수로_낸다() -> None: + """⚠ 실무 시트가 `−274.66` 이라 부호를 넘기면 **두 번 뒤집힌다.**""" + got = 성분()["채집석"] + assert got.unit == "㎥" + assert got.amount > 0 + + +def test_여기서_빼지_않는다는_것이_근거에_적힌다() -> None: + basis = 성분()["채집석"].basis + assert "사토에서 한 번만" in basis + + +def test_자재총괄에_안_섞인다() -> None: + """⚠ 자재는 `material` 만 모은다 — 채집석이 자재로 서면 같은 돌을 두 번 센다.""" + unit = build_table([구조물()]) + material = build_material_table(unit) + assert all(row["name"] != "채집석" for row in material["rows"]) + + +def test_구조물마다_갈리고_합산해서_하나로_간다() -> None: + unit = build_table( + [ + 구조물(), + { + "structure_id": "s2", + "type_id": "masonry_dry", + "start_m": 20.0, + "end_m": 30.0, + "options": { + "height_m": 2.0, + "length_m": 10.0, + "back_len_cm": 35, + "stone_supply": "구입", + }, + }, + ] + ) + only_collected = 성분()["채집석"].amount + assert unit[COLLECTED_STONE_KEY] == round(only_collected, 3) + assert build_handoff(unit_quantity_table=unit)["collected_stone_deduction_m3"] == round( + only_collected, 3 + ) diff --git a/resources/tester/test_b08_earthwork_summary.py b/resources/tester/test_b08_earthwork_summary.py new file mode 100644 index 00000000..6e73018a --- /dev/null +++ b/resources/tester/test_b08_earthwork_summary.py @@ -0,0 +1,286 @@ +"""토공집계표·프로젝트 설정 검사 — PLAN 8-11·8-13·8-7. + +여기서 못 박는 것 셋 + · 암 갈래 **개수를 코드에 안 박음** — 공사마다 다르다(울진 2 · 거창 5 · BOM 1). + · 반영률 **기본 100 %** — 실무 관측 80/50/80 은 기본값이 아니다(★법대로). + · 무대는 **집계에는 오르되 내역 줄이 아니다** — 품셈 1-2-7. +""" + +from __future__ import annotations + +import json +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_EarthworkSummary import ( # noqa: E402 + SummaryInput, + build_rows, + build_table, + haul_check, + totals_by_unit, +) +from common_util.common_util_project_settings import ( # noqa: E402 + APPLICATION_RATIO_KEYS, + ROCK_CLASS_SETS, + application_ratio, + default_settings, + load_settings, + quantity_settings, + rock_classes, + save_section, +) + + +def 토적표합계() -> dict[str, float]: + return { + "cut_soil_volume_m3": 2526.99, + "cut_rock_volume_m3": 3512.09, + "ditch_soil_volume_m3": 90.51, + "ditch_rock_volume_m3": 101.22, + "adjusted_total_m3": 6511.06, + "fill_volume_m3": 16836.47, + "diverted_m3": 5801.21, + } + + +def 사면합계() -> dict[str, float]: + return { + "face_dressing_fill": 13518.6, + "face_dressing_cut": 5433.7, + "tree_removal_fill": 13518.6, + "tree_removal_cut": 5433.7, + "bench_cut_fill": 13518.6, + } + + +# ── 암 갈래 — 개수를 코드에 박지 않는다 ───────────────────────────── + + +def test_비율이_없으면_암을_쪼개지_않음() -> None: + """지어낸 비율로 나누지 않는다 — 「암」 한 줄로 낸다.""" + rows = build_rows( + SummaryInput( + earthwork_totals=토적표합계(), + rock_classes=list(ROCK_CLASS_SETS["geochang5"]), + rock_ratios_pct={}, + ) + ) + cut = [r for r in rows if r.group == "흙깎기"] + assert [r.item for r in cut] == ["토사", "암"] + assert cut[1].amount == pytest.approx(3512.09) + + +def test_울진2갈래() -> None: + rows = build_rows( + SummaryInput( + earthwork_totals=토적표합계(), + rock_classes=list(ROCK_CLASS_SETS["uljin2"]), + rock_ratios_pct={"연암": 20, "발파암": 80}, + ) + ) + cut = [r for r in rows if r.group == "흙깎기"] + assert [r.item for r in cut] == ["토사", "연암", "발파암"] + assert cut[1].amount == pytest.approx(3512.09 * 0.2) + assert cut[2].amount == pytest.approx(3512.09 * 0.8) + + +def test_거창5갈래() -> None: + """같은 코드가 갈래 수만 바꿔 선다 — 개수를 박지 않았다는 증거.""" + rows = build_rows( + SummaryInput( + earthwork_totals=토적표합계(), + rock_classes=list(ROCK_CLASS_SETS["geochang5"]), + rock_ratios_pct={"풍화암": 10, "연암": 60, "보통암": 25, "경암": 5}, + ) + ) + cut = [r for r in rows if r.group == "흙깎기"] + assert [r.item for r in cut] == ["토사", "풍화암", "연암", "보통암", "경암"] + assert sum(r.amount for r in cut[1:]) == pytest.approx(3512.09) + + +def test_비율_합이_100이_아니어도_총량은_보존() -> None: + """설계자가 60/30 만 넣어도 암 총량이 새면 안 된다 — 준 비율끼리 안분한다.""" + rows = build_rows( + SummaryInput( + earthwork_totals=토적표합계(), + rock_classes=list(ROCK_CLASS_SETS["uljin2"]), + rock_ratios_pct={"연암": 60, "발파암": 30}, + ) + ) + cut = [r for r in rows if r.group == "흙깎기"] + assert sum(r.amount for r in cut[1:]) == pytest.approx(3512.09) + # 60/30 이 실제로는 66.7/33.3 으로 돈다 — 값이 말없이 바뀌므로 비고에 드러낸다. + assert cut[1].amount == pytest.approx(3512.09 * 60 / 90) + assert "90" in cut[1].note and "안분" in cut[1].note + + +def test_비율_합이_100이면_비고가_비어_있음() -> None: + """제대로 넣었는데 안내가 뜨면 잡음이 된다.""" + rows = build_rows( + SummaryInput( + earthwork_totals=토적표합계(), + rock_classes=list(ROCK_CLASS_SETS["uljin2"]), + rock_ratios_pct={"연암": 20, "발파암": 80}, + ) + ) + cut = [r for r in rows if r.group == "흙깎기"] + assert all(r.note == "" for r in cut[1:]) + + +# ── 반영률 — 기본 100 % ───────────────────────────────────────── + + +def test_반영률_기본은_100퍼센트() -> None: + rows = build_rows(SummaryInput(earthwork_totals=토적표합계(), slope_totals=사면합계())) + compaction = next(r for r in rows if r.group == "성토면다짐") + assert compaction.amount == pytest.approx(13518.6) + assert compaction.note == "" # 기본이면 비고에 아무것도 안 적는다 + + +def test_반영률을_주면_곱해지고_비고에_남음() -> None: + """실무 시트가 비고란에 적던 그 자리다 — 누가 정한 값인지 보이게 한다.""" + rows = build_rows( + SummaryInput( + earthwork_totals=토적표합계(), + slope_totals=사면합계(), + application_ratios={"fill_slope_compaction": 0.8}, + ) + ) + compaction = next(r for r in rows if r.group == "성토면다짐") + assert compaction.amount == pytest.approx(13518.6 * 0.8) + assert "80" in compaction.note + + +def test_초류종자살포는_성토_절토_따로() -> None: + rows = build_rows( + SummaryInput( + earthwork_totals=토적표합계(), + slope_totals=사면합계(), + application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0}, + ) + ) + seed = next(r for r in rows if r.group == "초류종자살포") + assert seed.amount == pytest.approx(13518.6 * 0.5 + 5433.7) + + +# ── 무대 — 집계에는 오르되 내역 줄이 아니다 ────────────────────── + + +def test_무대는_내역줄이_아님() -> None: + """품셈 1-2-7 — 소운반 20m 는 품에 포함이라 붙일 단가가 없다.""" + source = SummaryInput( + earthwork_totals=토적표합계(), + haul_rows=[ + {"equipment": "free_haul", "ground": "토사", "volume_m3": 871, "average_distance_m": 11.94}, + {"equipment": "dozer", "ground": "토사", "volume_m3": 1170, "average_distance_m": 43.66}, + {"equipment": "dump_truck", "ground": "암", "volume_m3": 1714, "average_distance_m": 318.6}, + ], + ) + rows = build_rows(source) + free = next(r for r in rows if r.group.startswith("무대")) + assert free.in_bill is False + assert "내역 제외" in free.note + assert free.amount == pytest.approx(871) + assert all(r.in_bill for r in rows if r.group in ("도자운반", "덤프운반")) + + +def test_무대를_내되_검산에_씀() -> None: + """무대를 아예 안 내면 `무대+도자+덤프 = 총 운반토량` 검산이 죽는다.""" + source = SummaryInput( + earthwork_totals=토적표합계(), + haul_rows=[ + {"equipment": "free_haul", "volume_m3": 1000}, + {"equipment": "dozer", "volume_m3": 2000}, + {"equipment": "dump_truck", "volume_m3": 2801.21}, + ], + ) + check = haul_check(source, 토적표합계()) + assert check["hauled_total_m3"] == pytest.approx(5801.21) + assert check["difference_m3"] == pytest.approx(0.0, abs=0.01) + + +def test_평균운반거리가_비고에_남음() -> None: + rows = build_rows( + SummaryInput( + earthwork_totals=토적표합계(), + haul_rows=[{"equipment": "dozer", "ground": "토사", "volume_m3": 1170, "average_distance_m": 43.66}], + ) + ) + dozer = next(r for r in rows if r.group == "도자운반") + assert "43.66" in dozer.note + + +# ── 표 모양 ───────────────────────────────────────────────────── + + +def test_열_구성은_실무_시트_그대로() -> None: + table = build_table(SummaryInput(earthwork_totals=토적표합계())) + assert table["columns"] == ["구분", "공종", "규격", "단위", "계", "비고"] + + +def test_단위를_섞어_더하지_않음() -> None: + rows = build_rows(SummaryInput(earthwork_totals=토적표합계(), slope_totals=사면합계())) + totals = totals_by_unit(rows) + assert set(totals) == {"㎥", "㎡"} + + +# ── 프로젝트 설정 ──────────────────────────────────────────────── + + +def test_기본설정_모양(tmp_path: Path) -> None: + settings = default_settings() + assert settings["schema_version"] == 1 + assert set(settings) == {"schema_version", "quantity", "estimation"} + quantity = settings["quantity"] + # override 는 기본이 None — 「안 정했으면 config 정본을 쓴다」는 뜻. + assert quantity["conversion_factors_override"] is None + assert quantity["haul_limits_m_override"] is None + # 반영률 기본 100. 실무 관측 80/50/80 을 넣지 않는다. + assert quantity["application_ratios_pct"] == {key: 100 for key in APPLICATION_RATIO_KEYS} + # estimation 은 자리만 — 채우는 것은 B09 몫. + # ⚠ 「연도」가 아니라 **판**을 가리킨다 — 제비율은 연중에도 개정된다(현행판 2026-04-13). + assert settings["estimation"]["rate_dataset"] is None + assert "rate_year" not in settings["estimation"] + + +def test_파일이_없으면_기본값(tmp_path: Path) -> None: + assert load_settings(tmp_path) == default_settings() + + +def test_깨진_파일이어도_화면은_서야_함(tmp_path: Path) -> None: + (tmp_path / "project_settings.json").write_text("{ 망가짐", encoding="utf-8") + assert load_settings(tmp_path) == default_settings() + + +def test_한_구획만_갈아끼움(tmp_path: Path) -> None: + """두 페이지가 같은 파일을 쓴다 — 통째로 덮으면 상대 값이 사라진다.""" + save_section(tmp_path, "estimation", {"rate_dataset": {"dataset_id": "rates", "effective_date": "2026-04-13"}}) + save_section(tmp_path, "quantity", {"rock_class_set": "uljin2"}) + stored = json.loads((tmp_path / "project_settings.json").read_text(encoding="utf-8")) + # 남의 구획이 살아 있다 — B08 이 저장해도 B09 값이 안 지워진다. + assert stored["estimation"]["rate_dataset"]["effective_date"] == "2026-04-13" + assert stored["quantity"]["rock_class_set"] == "uljin2" + + +def test_모르는_구획은_거부(tmp_path: Path) -> None: + with pytest.raises(ValueError): + save_section(tmp_path, "hacked", {}) + + +def test_암갈래는_세트에서(tmp_path: Path) -> None: + save_section(tmp_path, "quantity", {"rock_class_set": "uljin2", "rock_classes": None}) + settings = quantity_settings(tmp_path) + settings["rock_classes"] = None + assert rock_classes(settings) == list(ROCK_CLASS_SETS["uljin2"]) + + +def test_반영률_읽기() -> None: + settings = default_settings()["quantity"] + assert application_ratio(settings, "obstacle_removal") == pytest.approx(1.0) + settings["application_ratios_pct"]["obstacle_removal"] = 80 + assert application_ratio(settings, "obstacle_removal") == pytest.approx(0.8) diff --git a/resources/tester/test_b08_earthwork_table.py b/resources/tester/test_b08_earthwork_table.py new file mode 100644 index 00000000..59b3ddc0 --- /dev/null +++ b/resources/tester/test_b08_earthwork_table.py @@ -0,0 +1,184 @@ +"""토적표 엔진 검사 — PLAN 8-4b·8-16. + +정답지는 거창 실무 토적표(오솔길 `1.BOM` 과 1:1)다. 평균단면적법이 실물과 같은 값을 +내는지, 첫 측점에 체적이 없는지, 값을 자르지 않는지를 못 박는다. +""" + +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_EarthworkTable import ( # noqa: E402 + StationArea, + build_rows, + build_table, + totals, +) +from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402 + +SOIL_C = EARTHWORK_CONVERSION_FACTORS["soil"]["compacted"] + + +def 거창_앞_네_측점() -> list[StationArea]: + """거창 `1.BOM` 앞부분 — 절토 토사만 있고 측구는 단면 0.18㎡ 고정인 구간. + + BOM 값: 0(0.00) · 0+10(1.89) · 1(1.73) · 1+10(1.45), 거리 10m 등간. + """ + return [ + StationArea(chainage_m=0.0, cut_soil_area_m2=0.00, ditch_area_m2=0.0), + StationArea(chainage_m=10.0, cut_soil_area_m2=1.89, ditch_area_m2=0.18), + StationArea(chainage_m=20.0, cut_soil_area_m2=1.73, ditch_area_m2=0.18), + StationArea(chainage_m=30.0, cut_soil_area_m2=1.45, ditch_area_m2=0.18), + ] + + +def test_첫_측점은_체적이_없음() -> None: + """앞 측점이 없으면 평균을 낼 수 없다 — 실무 토적표도 첫 행 체적이 비어 있다.""" + rows = build_rows(거창_앞_네_측점()) + assert rows[0].distance_m == 0.0 + assert rows[0].cut_soil_volume_m3 == 0.0 + assert rows[0].adjusted_total_m3 == 0.0 + + +def test_평균단면적법_실무값_재현() -> None: + """BOM 0+10 행: 단면적 1.89 → 체적 9.45 → 보정 8.50 (거리 10m).""" + rows = build_rows(거창_앞_네_측점()) + row = rows[1] + assert row.distance_m == pytest.approx(10.0) + assert row.cut_soil_volume_m3 == pytest.approx(9.45) # (0.00+1.89)/2 × 10 + assert row.cut_soil_adjusted_m3 == pytest.approx(9.45 * SOIL_C) + assert row.cut_soil_adjusted_m3 == pytest.approx(8.505) # BOM 표기 8.50 + + +def test_평균단면적법_다음_측점() -> None: + """BOM 1 행: (1.89+1.73)/2 × 10 = 18.10 → 보정 16.29.""" + row = build_rows(거창_앞_네_측점())[2] + assert row.cut_soil_volume_m3 == pytest.approx(18.10) + assert row.cut_soil_adjusted_m3 == pytest.approx(16.29) + + +def test_측구_단면_0_18이_체적으로() -> None: + """측구 0.18㎡ 가 이어지면 10m 당 1.80㎥ — BOM 10열과 같다.""" + rows = build_rows(거창_앞_네_측점()) + assert rows[2].ditch_soil_volume_m3 == pytest.approx(1.80) + assert rows[2].ditch_soil_adjusted_m3 == pytest.approx(1.62) + + +def test_보정량계는_네_갈래_합() -> None: + row = build_rows(거창_앞_네_측점())[2] + assert row.adjusted_total_m3 == pytest.approx( + row.cut_soil_adjusted_m3 + + row.cut_rock_adjusted_m3 + + row.ditch_soil_adjusted_m3 + + row.ditch_rock_adjusted_m3 + ) + assert row.adjusted_total_m3 == pytest.approx(16.29 + 1.62) # BOM 15열 17.91 + + +def test_누가토량은_차인토량의_누계() -> None: + """유토곡선의 원본이 이 열이다 — 누계가 어긋나면 곡선이 통째로 틀린다.""" + rows = build_rows(거창_앞_네_측점()) + running = 0.0 + for row in rows: + running += row.balance_m3 + assert row.cumulative_m3 == pytest.approx(running) + + +def test_성토가_있으면_유용토는_작은_쪽() -> None: + stations = [ + StationArea(chainage_m=0.0), + StationArea(chainage_m=10.0, cut_soil_area_m2=2.0, fill_area_m2=8.0), + ] + row = build_rows(stations)[1] + assert row.adjusted_total_m3 == pytest.approx(10.0 * SOIL_C) # 절취 10㎥ → 보정 9㎥ + assert row.fill_volume_m3 == pytest.approx(40.0) + assert row.diverted_m3 == pytest.approx(9.0) # 둘 중 작은 쪽 + assert row.balance_m3 == pytest.approx(9.0 - 40.0) + + +def test_암은_토사와_다른_계수() -> None: + """토사 0.90 · 리핑암 1.15 — 한 계수로 뭉치면 암 물량이 틀린다.""" + stations = [ + StationArea(chainage_m=0.0), + StationArea(chainage_m=10.0, cut_rock_area_m2=2.0, cut_rock_kind="ripping_rock"), + ] + row = build_rows(stations)[1] + assert row.cut_rock_volume_m3 == pytest.approx(10.0) + assert row.cut_rock_adjusted_m3 == pytest.approx( + 10.0 * EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"] + ) + + +def test_측구_안분은_절토_토사암_비율() -> None: + """측구 지반이 따로 안 오므로 그 측점 절토 비율로 가른다(엔진 주석의 TODO 자리).""" + stations = [ + StationArea(chainage_m=0.0), + StationArea( + chainage_m=10.0, + cut_soil_area_m2=3.0, + cut_rock_area_m2=1.0, + ditch_area_m2=0.4, + cut_rock_kind="ripping_rock", + ), + ] + row = build_rows(stations)[1] + assert row.ditch_soil_area_m2 == pytest.approx(0.3) + assert row.ditch_rock_area_m2 == pytest.approx(0.1) + + +def test_값을_자르지_않을것() -> None: + """품셈 1-2-2 는 표기 규칙이다 — 엔진은 전정밀로 둔다(PLAN 8-16).""" + stations = [StationArea(chainage_m=0.0), StationArea(chainage_m=7.0, cut_soil_area_m2=1.0)] + row = build_rows(stations)[1] + assert row.cut_soil_volume_m3 == pytest.approx(3.5) + assert row.cut_soil_adjusted_m3 == pytest.approx(3.15) + assert repr(row.cut_soil_adjusted_m3) != "3.2" + + +def test_합계행() -> None: + rows = build_rows(거창_앞_네_측점()) + total = totals(rows) + assert total["distance_m"] == pytest.approx(30.0) + assert total["cut_soil_volume_m3"] == pytest.approx( + sum(r.cut_soil_volume_m3 for r in rows) + ) + + +def test_표_모양() -> None: + table = build_table(거창_앞_네_측점()) + assert table["method"] == "average_end_area" + assert table["station_count"] == 4 + assert table["conversion_factors"] is EARTHWORK_CONVERSION_FACTORS + assert len(table["rows"]) == 4 + assert "cut_soil_adjusted_m3" in table["rows"][1] + + +def test_설계결과에서_담기() -> None: + """B06 설계 결과의 키 이름을 그대로 받는다 — 이름이 어긋나면 0 이 조용히 들어간다.""" + design = { + "cut_soil_area_m2": 1.5, + "cut_rock_area_m2": 0.5, + "fill_area_m2": 2.0, + "ditch_area_m2": 0.18, + "cut_rock_kind": "blasting_rock", + } + area = StationArea.from_design(20.0, design) + assert area.chainage_m == 20.0 + assert area.cut_soil_area_m2 == 1.5 + assert area.cut_rock_kind == "blasting_rock" + + +def test_측점_순서가_뒤죽박죽이어도_이정순으로() -> None: + stations = [ + StationArea(chainage_m=20.0, cut_soil_area_m2=1.0), + StationArea(chainage_m=0.0), + StationArea(chainage_m=10.0, cut_soil_area_m2=2.0), + ] + rows = build_rows(stations) + assert [r.chainage_m for r in rows] == [0.0, 10.0, 20.0] diff --git a/resources/tester/test_b08_formwork.py b/resources/tester/test_b08_formwork.py new file mode 100644 index 00000000..14a5879f --- /dev/null +++ b/resources/tester/test_b08_formwork.py @@ -0,0 +1,120 @@ +"""거푸집 사용횟수 검사 — 품셈 1-7-1 · 2026-09-07 ⑩. + +⚠ 사용횟수는 **관측값이 아니라 법**이다 — 품셈 1-7-1 이 구조물 종류별로 정해 둔다. +⚠⚠ **횟수별 재료 환산은 여기서 하지 않는다.** 품셈 12-4 의 비율(%)은 일위대가 재료비에 + 걸리는 값이라, B08 이 곱해 넘기면 B09 가 또 곱해 두 번 준다. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Formwork import ( # noqa: E402 + FORMWORK_NAMES, + NOTE_REUSE_MISSING, + FormworkTable, + annotate, + load_formwork_table, + shoring_status, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + + +def 옹벽() -> dict: + return { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 100.0, + "end_m": 110.0, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, + } + + +def test_원문에_이름이_있는_구조물은_횟수가_붙음() -> None: + """품셈 1-7-1 의 3회 줄에 「옹벽」이 그대로 있다.""" + table = build_table([옹벽()], {"retaining_wall": "옹벽"}) + forms = [c for s in table["structures"] for c in s["components"] if c["name"] in FORMWORK_NAMES] + assert forms + assert all(c["reuse_count"] == 3 for c in forms) + assert all("1-7-1" in c["reuse_note"] for c in forms) + assert table["formwork_reuse_missing"] == [] + + +def test_횟수별_비율을_여기서_곱하지_않을것() -> None: + """⚠ 이 시험이 이 파일의 핵심 — 합판 3회 46.1 % 를 여기서 곱하면 B09 와 겹쳐 두 번 준다. + B08 이 내는 것은 **접촉 면적 그대로**이고 횟수는 옆에 적기만 한다.""" + table = build_table([옹벽()], {"retaining_wall": "옹벽"}) + euroform = next( + c for s in table["structures"] for c in s["components"] if c["name"] == "유로폼" + ) + assert euroform["amount"] == 32.0 # 3.20 ㎡/m × 10m — 46.1 % 를 곱하지 않았다 + # 비율표는 데이터에 있되 값에 안 걸린다. + assert load_formwork_table().reuse_ratio_pct["plywood"]["3"] == 46.1 + + +def test_모르는_종류는_지어내지_않고_미확보() -> None: + structures = [ + { + "type_id": "듣도보도못한구조물", + "name": "무엇", + "components": [{"name": "합판거푸집", "unit": "㎡", "amount": 5.0}], + } + ] + notes, missing = annotate(structures) + assert missing == ["듣도보도못한구조물"] + assert structures[0]["components"][0]["reuse_count"] is None + assert structures[0]["components"][0]["reuse_note"] == NOTE_REUSE_MISSING + + +def test_거푸집이_없는_구조물은_건드리지_않음() -> None: + structures = [ + { + "type_id": "retaining_wall", + "components": [{"name": "콘크리트", "unit": "㎥", "amount": 1.0}], + } + ] + notes, missing = annotate(structures) + assert notes == [] and missing == [] + assert "reuse_count" not in structures[0]["components"][0] + + +def test_이름은_정확히_일치로만_본다() -> None: + """부분일치면 「거푸집씻기」(공사용수 항목)가 거푸집으로 잡힌다.""" + structures = [ + { + "type_id": "retaining_wall", + "components": [{"name": "거푸집씻기", "unit": "㎥", "amount": 1.0}], + } + ] + annotate(structures) + assert "reuse_count" not in structures[0]["components"][0] + + +def test_파일이_없으면_전부_미확보() -> None: + structures = [ + { + "type_id": "retaining_wall", + "components": [{"name": "유로폼", "unit": "㎡", "amount": 1.0}], + } + ] + notes, missing = annotate(structures, FormworkTable()) + assert missing == ["retaining_wall"] + + +# ── 동바리 ────────────────────────────────────────────────────────── + + +def test_동바리는_대상이_없으면_없다고_말할것() -> None: + """0 으로 적으면 「대상이 없음」과 「값이 0」이 구별되지 않는다.""" + status = shoring_status() + assert status["applicable"] is False + assert status["reason"] + assert set(status["pending_types"]) == {"box_culvert", "ford_bridge"} + + +def test_표에도_동바리_상태가_실림() -> None: + assert build_table([옹벽()])["shoring"]["applicable"] is False diff --git a/resources/tester/test_b08_frame_material.py b/resources/tester/test_b08_frame_material.py new file mode 100644 index 00000000..3f2c85aa --- /dev/null +++ b/resources/tester/test_b08_frame_material.py @@ -0,0 +1,77 @@ +"""규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리** (2026-09-09). + +품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지 +「안 센다」가 아니다. 근주이식·임목파쇄(**셀지 말지가 설계 판단**)와 성격이 다르다. +그래서 **제안값을 보이고 고칠 수 있게** 둔다 — 확정 ⑨·⑩ 과 같은 틀. + +⚠ 겨누는 것 여섯 + ① 개소가 서면 재료도 선다 + ② ⚠ 개소가 안 서면 재료도 안 선다 — 밑수가 그 줄이다 + ③ 값은 **제안값**이고 산출 조건이 이긴다 + ④ ⚠ 제안값이 **실무 관측값이지 법정 기준이 아님**이 사유에 적힌다 + ⑤ 손율은 원문값이 함께 적힌다(비탈 50% · 수평 80%) + ⑥ 자재 축으로 간다(할증은 자재총괄에서 한 번만) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 + FRAME_MATERIAL_SUGGESTED, + frame_material_rows, +) + +규준틀 = [ + {"item": "비탈 규준틀", "unit": "개소", "amount": 28.0}, + {"item": "수평 규준틀", "unit": "개소", "amount": 33.0}, +] + + +def 이름별(rows: list[dict]) -> dict: + return {(row["source"], row["name"]): row for row in rows} + + +def test_개소가_서면_재료도_선다() -> None: + rows = 이름별(frame_material_rows(규준틀)) + assert abs(rows[("비탈 규준틀", "각재 50×50")]["amount"] - 28.0 * 0.0044) < 1e-9 + assert abs(rows[("수평 규준틀", "못")]["amount"] - 33.0 * 0.03) < 1e-9 + + +def test_개소가_안_서면_재료도_안_선다() -> None: + """⚠ 밑수가 개소 줄이다 — 개소가 없으면 재료를 지어내지 않는다.""" + assert frame_material_rows([{"item": "비탈 규준틀", "amount": None}]) == [] + + +def test_산출_조건이_이긴다() -> None: + rows = 이름별(frame_material_rows(규준틀, {"못": 0.05})) + assert abs(rows[("비탈 규준틀", "못")]["amount"] - 28.0 * 0.05) < 1e-9 + assert "고른 값" in rows[("비탈 규준틀", "못")]["basis"] + + +def test_관측값이지_법정_기준이_아님이_적힌다() -> None: + """⚠ 값만 박고 근거를 안 보이면 「대신 페이지에 남길 것」 지시를 어기는 것이다.""" + basis = 이름별(frame_material_rows(규준틀))[("비탈 규준틀", "각재 50×50")]["basis"] + assert "실무 관측값" in basis and "법정 기준 아님" in basis + assert "설계수량에 따른다" in basis + + +def test_손율이_원문값으로_적힌다() -> None: + rows = 이름별(frame_material_rows(규준틀)) + assert "손율 50%" in rows[("비탈 규준틀", "판재 T12")]["basis"] + assert "손율 80%" in rows[("수평 규준틀", "판재 T12")]["basis"] + + +def test_자재_축으로_간다() -> None: + assert all(row["destination"] == "material" for row in frame_material_rows(규준틀)) + + +def test_제안값이_실무_관측값과_같다() -> None: + """울진 소광 §8 — 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏(개소당).""" + assert FRAME_MATERIAL_SUGGESTED["각재 50×50"][0] == 0.0044 + assert FRAME_MATERIAL_SUGGESTED["판재 T12"][0] == 0.0029 + assert FRAME_MATERIAL_SUGGESTED["못"][0] == 0.03 diff --git a/resources/tester/test_b08_ground_alias.py b/resources/tester/test_b08_ground_alias.py new file mode 100644 index 00000000..4a0615a9 --- /dev/null +++ b/resources/tester/test_b08_ground_alias.py @@ -0,0 +1,40 @@ +"""갈래 이름 별칭 — 우리 「리핑암」 ↔ 일위대가 「파쇄암」 (2026-09-08). + +⚠ 겨누는 것 넷 + ① 인계본에 별칭이 실린다 — 받는 쪽이 이름만 달라 못 붙이던 자리(도자 운반 ≒ 30만원) + ② ⚠ 갈래 이름 **자체는 안 바뀐다** — 흙깎기(FP-09-04)가 「리핑암」으로 서 있다 + ③ 근거가 데이터에 적혀 있다(품셈 10-11 f 「파쇄암 1/1.35」 = 10-12 [주]③ 「암절취 1.35」) + ④ 별칭 표가 비어도 인계본은 그 칸을 갖는다(빈 칸과 없는 칸을 가른다) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import WorkItemMapping # noqa: E402 + + +def test_인계본에_별칭이_실린다() -> None: + aliases = build_handoff()["ground_class_aliases"] + assert "파쇄암" in aliases["리핑암"]["names"] + + +def test_갈래_이름_자체는_안_바뀐다() -> None: + """⚠ 이름을 갈면 흙깎기 매핑(FP-09-04 리핑암)이 어긋난다 — 잇기만 한다.""" + mapping = load_mapping() + assert mapping.for_earthwork("흙깎기", "리핑암")["work_item_code"] == "FP-09-04" + assert mapping.for_earthwork("흙깎기", "파쇄암") is None + + +def test_근거가_데이터에_적혀_있다() -> None: + basis = load_mapping().ground_aliases["aliases"]["리핑암"]["basis"] + assert "10-11" in basis and "10-12" in basis and "1.35" in basis + + +def test_표가_비어도_칸은_있다() -> None: + assert build_handoff(mapping=WorkItemMapping())["ground_class_aliases"] == {} diff --git a/resources/tester/test_b08_handoff.py b/resources/tester/test_b08_handoff.py new file mode 100644 index 00000000..c0bf957f --- /dev/null +++ b/resources/tester/test_b08_handoff.py @@ -0,0 +1,1342 @@ +"""B08 → B09 인계 검사 — PLAN 8-2 · 2026-09-07 3자 조율 계약. + +이 일감의 위험은 **축을 섞는 것**이다. + · 자재 줄에 공종코드가 붙으면 자재가 내역 줄로 오해된다 — 그게 곧 이중계상이다. + · `in_bill` 을 안 실으면 무대(㉡)가 ④예산내역서에 서서 운반비가 두 번 붙는다. + · 못 이은 줄을 빈 코드로 두면 「없어진 줄」과 구별이 안 된다. +""" + +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_Handoff import ( # noqa: E402 + WorkItemMapping, + build_handoff, + iter_bill_rows, + load_mapping, + summarize, + verify_bill_flags, + verify_no_code_on_materials, +) +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 + SUPPLY_OWNER, + build_table as build_material_table, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + build_table as build_unit_table, +) + + +def 집계표(*rows: dict) -> dict: + return {"rows": list(rows)} + + +def 집계줄(group: str, item: str = "", amount: float = 1.0, **extra) -> dict: + return {"group": group, "item": item, "spec": "", "unit": "㎥", "amount": amount, **extra} + + +def 구조물() -> dict: + return { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 35.0, + "end_m": 45.0, + "options": {"height_m": 1.5, "length_m": 10.0}, + } + + +def 줄(handoff: dict, name: str) -> dict: + return next(row for row in handoff["work_items"] if row["name"] == name) + + +# ── 축 둘을 섞지 않는다 ───────────────────────────────────────────── + + +def test_자재_줄에는_공종코드가_없음() -> None: + """자재에 공종코드가 붙으면 내역 줄로 오해될 자리가 생긴다(8-2 이중계상 함정).""" + unit = build_unit_table([구조물()]) + handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit)) + assert handoff["materials"] + assert all("work_item_code" not in row for row in handoff["materials"]) + assert verify_no_code_on_materials(handoff) == [] + + +def test_섞이면_검사가_잡을것() -> None: + """규칙이 주석에만 있으면 지켜지지 않는다 — 오염된 모양을 만들어 검사를 시험한다.""" + tainted = {"materials": [{"material_name": "야면석", "work_item_code": "FP-13-04-05"}]} + assert verify_no_code_on_materials(tainted) == ["야면석"] + + +def test_구조물은_한_줄로_서고_전개_성분은_안_옴() -> None: + """내역 줄의 실체는 `돌쌓기(찰) H=1.5·10m` 이지 그 전개인 야면석이 아니다.""" + handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) + names = [row["name"] for row in handoff["work_items"]] + # ⚠ 2026-09-09 감사 — 종전에는 레지스트리 이름이 없으면 **코드값(`masonry_wet`)이 + # 그대로 내역에 떴다.** `_Wording.type_label` 이 이미 대비표를 들고 있어 그것을 쓰게 + # 고쳤다(옹벽이 `retaining_wall` 로 뜨던 자리에서 잡음). + assert "돌쌓기(찰)" in names + assert not any(name.isascii() and "_" in name for name in names), names + assert "야면석" not in names + assert "터파기" not in names + # ⚠ 돌쌓기 뒤채움(채움콘크리트)은 **타설 줄로 안 선다** — 그 공종의 품에 이미 + # 들어 있을 수 있어 세우면 이중계상이다(2026-09-08 ㉙). + # ⚠ **버림 콘크리트는 다름**(2026-09-09 확정 ⑭) — 기초 바닥에 따로 치는 것이라 + # 실무 내역도 「레미콘타설(장비) 무근,**버림**」을 별도 줄로 세운다. 그래서 여기서는 + # 타설 줄이 **하나 선다**. 그 줄이 무엇 때문에 섰는지까지 확인한다. + placing = [row for row in handoff["work_items"] if row["name"] == "콘크리트 타설"] + assert len(placing) == 1 + # 그 줄의 물량은 **버림 콘크리트 몫뿐**이어야 한다 — 채움이 섞이면 이중계상이다. + blinding = next( + c for c in build_unit_table([구조물()])["structures"][0]["components"] + if c["name"] == "버림콘크리트" + ) + assert placing[0]["quantity"] == pytest.approx(blinding["amount"], abs=1e-6) + + +def test_구조물_줄에_측점과_규격이_실림() -> None: + handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) + row = handoff["work_items"][0] + assert row["station_from"] == 35.0 + assert row["station_to"] == 45.0 + assert row["spec_detail"] == "H=1.5·L=10m" + # ⚠ 여기서 「10.0 m」를 계약으로 못 박고 있었다 — 그것이 곧 **금액 2.6배 사고**였다. + # 품셈 13-4-5 밑수는 **㎡** 이고, 받는 쪽은 ㎡ 단가를 이 수량에 그대로 곱한다. + # (오늘 다섯 번째로 옛 시험이 틀린 계약을 지키고 있던 자리다 — 2026-09-08.) + assert row["unit"] == "㎡" + # ⚠ 2026-09-09 — 기울기는 **성토/절토**까지 알아야 표에서 고를 수 있다(확정 ⑨). + # 이 시험은 단면유형을 안 주므로 판정이 「가를 근거 없음」이 되고, 그때는 **종전값 + # 1:0.3** 으로 서고 사유가 근거에 적힌다. 10m × 1.5m × √(1+0.3²) = 15.660. + # ⚠ 성토로 눅여 표를 고르면 임의값이 금액으로 굳는다 — 그래서 이 값이 맞다. + assert row["quantity"] == pytest.approx(15.660, abs=0.001) + assert row["origin"] == "structure" + + +# ── 할증은 자재 쪽에만 ────────────────────────────────────────────── + + +def test_할증_전후가_자재에만_실림() -> None: + unit = build_unit_table([구조물()]) + # ⚠ 타설 방식을 **비빔**으로 둔다 — 2026-09-09 부터 콘크리트가 자재 축에 서고(확정 3차 ⑥) + # 레미콘일 때는 율이 붙으므로, 「율이 없는 자재만 있는 상태」를 만들려면 비빔이라야 한다. + material = build_material_table(unit, concrete_placing_method="machine_mixed") + handoff = build_handoff( + summary_table=집계표(집계줄("흙깎기", "토사", 100.0)), + unit_quantity_table=unit, + material_table=material, + ) + # ⚠ 이 자재들은 율이 표에 없어 **실제로는 안 붙었다** — 깃발이 실제와 어긋나면 안 된다 + # (2026-09-07 계약 정정). 상태는 세 갈래로 말한다. + assert handoff["surcharge_status"] == "rate_unavailable" + assert handoff["surcharge_applied_to_materials"] is False + for row in handoff["materials"]: + assert "net_amount" in row and "total_amount" in row + # 작업 공종에는 할증 칸 자체가 없다. + for row in handoff["work_items"]: + assert "surcharge_pct" not in row + + +def test_관급구분과_설치주체가_함께_감() -> None: + unit = build_unit_table([구조물()]) + # ⚠ 돌 종류를 안 고른 구조물의 자재 이름이 「야면석」 → 「돌」로 바뀜(2026-09-09 + # 확정 5차 큰 것 7) — 관측표가 아니라 계산식으로 서고 이름도 정본 계산표대로다. + material = build_material_table( + unit, supply_map={"돌": {"supply": SUPPLY_OWNER, "install_by": "contractor"}} + ) + handoff = build_handoff(unit_quantity_table=unit, material_table=material) + row = next(r for r in handoff["materials"] if r["material_name"] == "돌") + assert row["supply_type"] == "owner_supplied" + assert row["install_by"] == "contractor" + + +# ── in_bill — 무대가 내역에 서면 안 된다 (㉡) ────────────────────── + + +def test_무대는_넘기되_내역에는_안_섬() -> None: + """빼고 넘기면 「빠진 줄」과 「제외된 줄」을 나중에 구별할 수 없다.""" + haul = { + "rows": [ + { + "equipment": "free_haul", + "ground": "토사", + "volume_m3": 800.0, + "average_distance_m": 11.9, + "in_bill": False, + }, + { + "equipment": "dump_truck", + "ground": "토사", + "volume_m3": 500.0, + "average_distance_m": 1200.0, + "in_bill": True, + }, + ] + } + handoff = build_handoff(haul_table=haul) + free = 줄(handoff, "free_haul 운반") + dump = 줄(handoff, "dump_truck 운반") + assert free["in_bill"] is False + assert free["in_bill_reason"] # 왜 빠지는지 함께 간다 + assert free["quantity"] == pytest.approx(800.0) # 값은 그대로 넘어간다 + assert dump["in_bill"] is True + assert dump["work_item_code"] == "FP-10-12" + assert dump["haul_distance_m"] == pytest.approx(1200.0) + + +def test_합계_줄은_내역에_안_섬() -> None: + """「보정량계」는 검산용이다 — 빼지 않고 깃발로 가른다.""" + handoff = build_handoff(summary_table=집계표(집계줄("보정량계", amount=6511.06))) + row = handoff["work_items"][0] + assert row["in_bill"] is False + assert handoff["unmatched_work_items"] == [] # 합계는 못 이은 것이 아니다 + + +def test_내역_줄만_뽑는_입구() -> None: + handoff = build_handoff( + summary_table=집계표(집계줄("흙깎기", "토사", 100.0), 집계줄("보정량계", amount=100.0)) + ) + assert [row["name"] for row in iter_bill_rows(handoff)] == ["흙깎기"] + assert handoff["bill_row_count"] == 1 + assert handoff["excluded_row_count"] == 1 + + +# ── 매핑 — 데이터에서 오고, 못 찾으면 드러낸다 ───────────────────── + + +def test_실제_매핑판이_읽힘() -> None: + mapping = load_mapping() + assert mapping.effective_date + assert mapping.for_earthwork("흙깎기", "토사")["work_item_code"] == "FP-09-03-02" + + +def test_지반유형별로_다른_코드() -> None: + """측구터파기는 토사·암절취·발파암이 품셈에서 갈린다.""" + mapping = load_mapping() + assert mapping.for_earthwork("측구터파기", "토사")["work_item_code"] == "FP-09-12-01" + assert mapping.for_earthwork("측구터파기", "발파암")["work_item_code"] == "FP-09-12-03" + + +def test_지반을_안_가르는_공종은_ground_없이_맞음() -> None: + mapping = load_mapping() + assert mapping.for_earthwork("층따기", None)["work_item_code"] == "FP-09-18" + assert mapping.for_earthwork("층따기", "토사")["work_item_code"] == "FP-09-18" + + +def test_못_이은_줄은_빈_코드로_두지_않고_목록으로() -> None: + """빈칸이면 「코드 없는 줄」과 「매핑을 못 찾은 줄」이 구별되지 않는다.""" + handoff = build_handoff(summary_table=집계표(집계줄("듣도보도못한공종", amount=5.0))) + assert handoff["work_items"][0]["work_item_code"] is None + assert handoff["unmatched_work_items"] == ["듣도보도못한공종"] + + +def test_코드가_없는데_내역에_서면_검사가_잡음() -> None: + """빈 코드로 내역에 세우면 B09 가 단가를 못 붙인 0원 줄을 만든다.""" + handoff = build_handoff(summary_table=집계표(집계줄("듣도보도못한공종", amount=5.0))) + assert verify_bill_flags(handoff) == ["듣도보도못한공종"] + + +def test_매핑_파일이_없으면_전부_드러남() -> None: + handoff = build_handoff( + summary_table=집계표(집계줄("흙깎기", "토사", 100.0)), mapping=WorkItemMapping() + ) + assert handoff["unmatched_work_items"] == ["흙깎기(토사)"] + + +def test_정할_근거가_없는_자리는_지어내지_않고_알림() -> None: + """지장목제거·암 갈래는 후보만 있고 고를 근거가 없다 — 임의 확정 금지(3장).""" + handoff = build_handoff(summary_table=집계표(집계줄("지장목제거", amount=100.0))) + assert handoff["work_items"][0]["work_item_code"] is None + assert "지장목제거" in handoff["unmatched_work_items"] + # ⚠ 미결 항목은 **공종(`group`)** 으로도 **구조물 종류(`type_id`)** 로도 들어온다 — + # 둘 다 「고를 근거가 없는 자리」라 한 목록에 담는다. 키가 하나뿐이라고 보면 깨진다. + keys = [ + item.get("group") or item.get("type_id") + for item in handoff["mapping_pending_user"]["items"] + ] + assert "지장목제거" in keys + # ⚠ 큰돌쌓기는 **닫혔다** — 랩탑이 `bond`(메쌓기/찰쌓기) 칸을 만들어 자동으로 갈린다. + # 미결 목록에서 빠졌는지도 함께 본다(고쳐졌는데 목록만 남는 것 방지). + assert "boulder_masonry" not in keys + + +# ── 사면 계열은 출처가 다르다 ────────────────────────────────────── + + +def test_사면_계열은_origin_이_slope() -> None: + handoff = build_handoff( + summary_table=집계표(집계줄("흙깎기", "토사", 100.0), 집계줄("성토면다짐", amount=50.0)) + ) + assert 줄(handoff, "흙깎기")["origin"] == "earthwork" + assert 줄(handoff, "성토면다짐")["origin"] == "slope" + + +def test_초류종자살포가_씨앗뿜어붙이기로_이어짐() -> None: + handoff = build_handoff(summary_table=집계표(집계줄("초류종자살포", amount=100.0))) + assert handoff["work_items"][0]["work_item_code"] == "FP-05-24" + + +# ── 요약 ──────────────────────────────────────────────────────────── + + +def test_요약이_못_이은_것을_숨기지_않음() -> None: + handoff = build_handoff( + summary_table=집계표( + 집계줄("흙깎기", "토사", 100.0), 집계줄("듣도보도못한공종", amount=1.0) + ) + ) + brief = summarize(handoff) + assert brief["bill_rows"] == 2 + assert brief["unmatched"] == ["듣도보도못한공종"] + + +def test_판_번호가_응답에_남음() -> None: + assert build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 1.0)))["mapping_edition"] + + +# ── 갈래 세트 (2026-09-07 서브 이견 채택) ────────────────────────── + + +def test_갈래_세트가_인계본에_실림() -> None: + """값이 「연암」이어도 몇 갈래 중 하나인지 알아야 ④에서 줄을 세운다(울진 2·거창 5).""" + handoff = build_handoff( + summary_table=집계표(집계줄("흙깎기", "연암", 100.0)), + ground_class_set="geochang5", + ground_classes=["토사", "풍화암", "연암", "보통암", "경암"], + ) + assert handoff["ground_class_set"] == "geochang5" + assert handoff["ground_classes"][2] == "연암" + assert handoff["work_items"][0]["ground_class"] == "연암" + + +def test_무대_줄에_수량이_그대로_실림() -> None: + """무대+도자+덤프 = 총 운반토량 검산에 쓰이는 값이다 — 빼면 검산이 죽는다.""" + haul = { + "rows": [ + { + "equipment": "free_haul", + "ground": "토사", + "volume_m3": 871.0, + "average_distance_m": 11.94, + "in_bill": False, + }, + ] + } + row = build_handoff(haul_table=haul)["work_items"][0] + assert row["in_bill"] is False + assert row["quantity"] == pytest.approx(871.0) + + +# ── 암 시공법 (2026-09-07 일감 9 실서버에서 드러난 자리) ─────────── + + +def test_시공법을_정하면_공종이_갈림() -> None: + """품셈은 긁어내기(암절취)와 터뜨리기(발파암)를 다른 공종으로 둔다.""" + ripping = build_handoff( + summary_table=집계표(집계줄("흙깎기", "연암", 100.0)), + ground_methods={"연암": "ripping"}, + ) + blasting = build_handoff( + summary_table=집계표(집계줄("흙깎기", "연암", 100.0)), + ground_methods={"연암": "blasting"}, + ) + assert ripping["work_items"][0]["work_item_code"] == "FP-09-04" + assert blasting["work_items"][0]["work_item_code"] == "FP-09-05" + # 갈래 이름은 그대로 남는다 — 바꿔치기하지 않는다. + assert ripping["work_items"][0]["ground_class"] == "연암" + assert ripping["work_items"][0]["excavation_method"] == "ripping" + + +def test_시공법을_안_정하면_찍지_않고_드러냄() -> None: + """잘못 찍으면 공종이 조용히 틀린다 — 기본값으로 때우지 않는다.""" + handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "보통암", 100.0))) + row = handoff["work_items"][0] + assert row["work_item_code"] is None + assert row["excavation_method"] is None + assert handoff["missing_method_classes"] == ["보통암"] + assert any("시공법" in item for item in handoff["unmatched_work_items"]) + + +def test_토사는_시공법이_필요없음() -> None: + handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 100.0))) + assert handoff["work_items"][0]["work_item_code"] == "FP-09-03-02" + assert handoff["missing_method_classes"] == [] + + +def test_측구터파기도_시공법을_따라감() -> None: + handoff = build_handoff( + summary_table=집계표(집계줄("측구터파기", "경암", 50.0)), + ground_methods={"경암": "blasting"}, + ) + assert handoff["work_items"][0]["work_item_code"] == "FP-09-12-03" + + +# ── 고른 값을 되돌릴 수 있어야 한다 (2026-09-07 화면에서 걸린 자리) ── + + +def test_시공법을_안_정함으로_되돌릴_수_있을것(tmp_path) -> None: + """병합 저장이면 한 번 고른 값이 영영 남는다 — 지울 수 있는 칸은 통째로 갈아 끼운다.""" + from common_util.common_util_project_settings import quantity_settings, save_section + + save_section( + tmp_path, "quantity", {"rock_methods": {"연암": "ripping"}}, replace_keys=("rock_methods",) + ) + assert quantity_settings(tmp_path)["rock_methods"] == {"연암": "ripping"} + # 「안 정함」으로 되돌리면 빈 칸이 되어야 한다. + save_section(tmp_path, "quantity", {"rock_methods": {}}, replace_keys=("rock_methods",)) + assert quantity_settings(tmp_path)["rock_methods"] == {} + + +def test_되돌리기_대상이_아닌_칸은_그대로_병합될것(tmp_path) -> None: + """`replace_keys` 를 안 준 칸까지 통째로 덮으면 남의 값이 사라진다.""" + from common_util.common_util_project_settings import quantity_settings, save_section + + save_section(tmp_path, "quantity", {"rock_ratios_pct": {"연암": 40}}) + save_section(tmp_path, "quantity", {"rock_ratios_pct": {"경암": 60}}) + assert quantity_settings(tmp_path)["rock_ratios_pct"] == {"연암": 40, "경암": 60} + + +# ── 반영률 — 곱하기는 B08 한 곳에서만 (2026-09-07 3자 계약) ──────── + + +def test_반영률_세_칸이_함께_감() -> None: + """율만 보내면 B09 가 또 곱해 값이 두 배가 된다 — 곱한 값·곱하기 전 값·율을 함께 싣는다.""" + handoff = build_handoff( + summary_table=집계표( + { + "group": "성토면다짐", + "item": "", + "spec": "", + "unit": "㎡", + "amount": 10814.9, + "amount_gross": 13518.62, + "application_ratio_pct": 80.0, + } + ) + ) + row = handoff["work_items"][0] + assert row["quantity"] == pytest.approx(10814.9) + assert row["quantity_gross"] == pytest.approx(13518.62) + assert row["application_ratio_pct"] == 80.0 + + +def test_세_칸이_어긋나면_검사가_잡음() -> None: + """세 값을 실어 두고 서로 안 맞으면 받는 쪽이 어느 값을 믿을지 모른다.""" + handoff = build_handoff( + summary_table=집계표( + { + "group": "성토면다짐", + "unit": "㎡", + "amount": 13518.62, # 곱하기를 빠뜨린 모양 + "amount_gross": 13518.62, + "application_ratio_pct": 80.0, + } + ) + ) + assert handoff["ratio_math_warnings"] + assert "성토면다짐" in handoff["ratio_math_warnings"][0] + + +def test_정상이면_경고가_없음() -> None: + """검사를 만들어 두고 안 부르면 없는 것과 같다 — 표가 실제로 부르는지 본다.""" + handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 100.0))) + assert handoff["ratio_math_warnings"] == [] + + +def test_반영률_개념이_없는_줄은_None() -> None: + """운반·구조물에는 반영률이 없다 — 0 이 아니라 `None` 이다.""" + handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) + assert handoff["work_items"][0]["application_ratio_pct"] is None + assert handoff["work_items"][0]["quantity_gross"] is None + + +def test_집계_엔진이_세_칸을_실제로_냄() -> None: + """엔진 출력이 계약대로인지 — 손으로 만든 모양이 아니라 진짜 산출물로 본다.""" + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary + + table = build_summary( + SummaryInput( + earthwork_totals={}, + slope_totals={ + "face_dressing_fill": 1000.0, + "face_dressing_cut": 500.0, + "tree_removal_fill": 1000.0, + "tree_removal_cut": 500.0, + "bench_cut_fill": 1000.0, + }, + rock_classes=["토사", "암"], + application_ratios={ + "fill_slope_compaction": 0.8, + "seed_spray_fill": 0.5, + "seed_spray_cut": 0.5, + "obstacle_removal": 0.8, + }, + ) + ) + packed = build_handoff(summary_table=table) + compaction = 줄(packed, "성토면다짐") + assert compaction["quantity"] == pytest.approx(800.0) + assert compaction["quantity_gross"] == pytest.approx(1000.0) + assert compaction["application_ratio_pct"] == pytest.approx(80.0) + # 100 % 인 줄도 율을 적는다 — 비어 있으면 「적용됐는지」를 받는 쪽이 못 정한다. + assert packed["ratio_math_warnings"] == [] + + +# ── 할증 상태는 세 갈래 ───────────────────────────────────────────── + + +def test_율을_못_찾았으면_붙였다고_말하지_않음() -> None: + """두 갈래로 두면 나중에 진짜 율이 들어왔을 때 B09 가 한 번 더 붙인다.""" + unit = build_unit_table([구조물()]) + # 야면석·막자갈 — 율이 표에 없다. 콘크리트는 비빔이라 레미콘 율이 안 붙는다(확정 3차 ⑥). + material = build_material_table(unit, concrete_placing_method="machine_mixed") + handoff = build_handoff(unit_quantity_table=unit, material_table=material) + assert handoff["surcharge_status"] == "rate_unavailable" + assert handoff["surcharge_applied_to_materials"] is False + + +def test_실제로_붙었으면_applied() -> None: + from B08_Quantity.B08_Quantity_Engine_MaterialSummary import SurchargeTable + + unit = { + "structures": [ + { + "name": "A", + "components": [ + {"name": "모래", "unit": "㎥", "amount": 10.0, "destination": "material"} + ], + } + ], + "surcharge_applied": False, + } + material = build_material_table( + unit, surcharge_table=SurchargeTable(rates={"모래": {"material": "모래", "rate": 6}}) + ) + handoff = build_handoff(unit_quantity_table=unit, material_table=material) + assert handoff["surcharge_status"] == "applied" + assert handoff["surcharge_applied_to_materials"] is True + + +def test_자재가_없으면_not_applied() -> None: + handoff = build_handoff(material_table=build_material_table({"structures": []})) + assert handoff["surcharge_status"] == "not_applied" + + +def test_관급구분_미분류가_계약값임() -> None: + """`unknown` 은 세 번째 값이다 — B09 는 이 줄을 관급에도 도급에도 안 넣는다.""" + unit = build_unit_table([구조물()]) + handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit)) + assert all(row["supply_type"] == "unknown" for row in handoff["materials"]) + + +# ── 운반 줄 (2026-09-07 조율 창 요청) ─────────────────────────────── +# +# 실물 프로젝트에서 운반 칸이 전부 `null` 로 나온 일이 있었다. 원인은 **종단 [확정] 전이라 +# 운반계획이 아직 없는 것**이지 결함이 아니었다(운반 표 자체가 빈 표였다). 다만 그 상태에서는 +# 서브의 ㉡ 가드가 **걸 것이 없어 놀고 있으므로**, 계획이 있을 때 실제로 실리는지를 못 박는다. + + +def 운반표() -> dict: + """운반계획이 있는 프로젝트가 내는 모양 — 수단 × 지반유형.""" + return { + "rows": [ + { + "equipment": "free_haul", + "ground": "토사", + "volume_m3": 871.0, + "average_distance_m": 11.94, + "in_bill": False, + }, + { + "equipment": "dozer", + "ground": "토사", + "volume_m3": 1240.0, + "average_distance_m": 42.0, + "in_bill": True, + }, + { + "equipment": "dump_truck", + "ground": "토사", + "volume_m3": 2100.0, + "average_distance_m": 1350.0, + "in_bill": True, + }, + { + "equipment": "dump_truck", + "ground": "발파암", + "volume_m3": 980.0, + "average_distance_m": 1350.0, + "in_bill": True, + }, + ] + } + + +def test_운반계획이_있으면_네_줄이_실림() -> None: + handoff = build_handoff(haul_table=운반표()) + haul_rows = [row for row in handoff["work_items"] if row["origin"] == "haul"] + assert len(haul_rows) == 4 + assert all(row["haul_equipment"] for row in haul_rows) + assert all(row["haul_distance_m"] for row in haul_rows) + + +def test_무대가_함께_와야_운반_검산이_걸림() -> None: + """무대를 빼고 넘기면 `무대+도자+덤프 = 총 운반토량` 검산이 죽는다.""" + handoff = build_handoff(haul_table=운반표()) + free = 줄(handoff, "free_haul 운반") + assert free["in_bill"] is False + assert free["quantity"] == pytest.approx(871.0) + assert handoff["excluded_row_count"] == 1 + assert handoff["bill_row_count"] == 3 + + +def test_운반계획이_없으면_줄이_없음() -> None: + """빈 표는 결함이 아니라 **종단 [확정] 전** 상태다 — 0 줄이 정상이다.""" + handoff = build_handoff(haul_table={"rows": []}) + assert [row for row in handoff["work_items"] if row["origin"] == "haul"] == [] + + +def test_율이_부분마다_다르면_칸으로_갈라_실림() -> None: + """비고 문장에 적으면 받는 쪽이 문자열을 뜯어야 한다 — 칸으로 준다(2026-09-07 계약).""" + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary + + table = build_summary( + SummaryInput( + slope_totals={"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, + rock_classes=["토사", "암"], + application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0}, + ) + ) + row = 줄(build_handoff(summary_table=table), "초류종자살포") + assert row["application_ratio_pct"] is None # 하나의 율로 못 적는다 + assert row["application_ratio_breakdown"] == {"fill": 50.0, "cut": 100.0} + assert row["quantity_breakdown"] == {"fill": pytest.approx(500.0), "cut": pytest.approx(500.0)} + assert row["quantity"] == pytest.approx(1000.0 * 0.5 + 500.0 * 1.0) + assert row["quantity_gross"] == pytest.approx(1500.0) + # 갈래 물량의 합이 총량과 맞아야 한다 — 안 맞으면 받는 쪽이 어느 값을 믿을지 모른다. + assert sum(row["quantity_breakdown"].values()) == pytest.approx(row["quantity"]) + + +def test_율이_같아도_breakdown_은_늘_실림() -> None: + """⚠ 계약 확정(2026-09-07) — **늘 breakdown**, `pct` 는 편의값이다. + 「같을 때만 pct, 다를 때만 breakdown」으로 두면 받는 쪽에 갈래가 둘 생기고 + 그게 나중에 **한쪽만 고쳐지는** 자리가 된다.""" + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary + + table = build_summary( + SummaryInput( + slope_totals={"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, + rock_classes=["토사", "암"], + application_ratios={"seed_spray_fill": 0.8, "seed_spray_cut": 0.8}, + ) + ) + row = 줄(build_handoff(summary_table=table), "초류종자살포") + assert row["application_ratio_pct"] == pytest.approx(80.0) # 편의값은 채워진다 + assert row["application_ratio_breakdown"] == {"fill": 80.0, "cut": 80.0} + assert row["quantity_breakdown"] == {"fill": pytest.approx(800.0), "cut": pytest.approx(400.0)} + + +def test_갈래별_율일_때_거울_검사가_헛경고를_안_냄() -> None: + """`quantity == gross × 율` 이 성립 안 하는 줄이므로 검사가 건너뛰어야 한다.""" + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary + + table = build_summary( + SummaryInput( + slope_totals={"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, + rock_classes=["토사", "암"], + application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0}, + ) + ) + assert build_handoff(summary_table=table)["ratio_math_warnings"] == [] + + +# ── 묶음으로 서는 줄 (2026-09-07 ⑩) ──────────────────────────────── + + +def test_품셈에_없는_공종은_묶음으로_적힘() -> None: + """품셈 12장에 「옹벽」 공종이 없다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit + + unit = build_unit( + [ + { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 100.0, + "end_m": 110.0, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, + } + ], + {"retaining_wall": "옹벽"}, + ) + handoff = build_handoff(unit_quantity_table=unit) + row = handoff["work_items"][0] + assert row["work_item_code"] is None + assert row["composite_parts"] # 무엇으로 묶이는지 적혀 있다 + assert "옹벽" in row["in_bill_reason"] + # 묶음 줄은 「코드 없이 내역에 선 줄」 경고에 걸리지 않는다. + assert verify_bill_flags(handoff) == [] + assert handoff["unmatched_work_items"] == [] + + +def test_집수정은_품셈_공종이_있어_바로_이어짐() -> None: + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit + + unit = build_unit( + [ + { + "structure_id": "p1", + "type_id": "pipe", + "start_m": 50.0, + "end_m": 50.0, + "options": {"inlet_basin_form": "돌집수정 ㄷ형"}, + } + ], + {"pipe": "배수관"}, + ) + handoff = build_handoff(unit_quantity_table=unit) + basin = next(r for r in handoff["work_items"] if "집수정" in r["name"]) + assert basin["work_item_code"] == "FP-12-15" + + +# ── 콘크리트 타설 갈래 (2026-09-07 3자 확정) ─────────────────────── + + +def test_철근_유무로_구조물_종류가_자동_판정될것() -> None: + """사람이 고르는 값이 아니다 — 옹벽 관측 원단위의 D13·D16 에서 그대로 나온다.""" + from B08_Quantity.B08_Quantity_Engine_Handoff import structure_kind + + with_rebar = {"components": [{"name": "이형철근 D13"}, {"name": "콘크리트"}]} + without = {"components": [{"name": "콘크리트"}, {"name": "합판거푸집"}]} + assert structure_kind(with_rebar) == "철근구조물" + assert structure_kind(without) == "무근구조물" + + +def test_옹벽이_철근구조물로_섬() -> None: + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit + + unit = build_unit( + [ + { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 100.0, + "end_m": 110.0, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, + } + ], + {"retaining_wall": "옹벽"}, + ) + row = build_handoff(unit_quantity_table=unit)["work_items"][0] + assert row["structure_kind"] == "철근구조물" + # ⚠ 계약이 바뀐 자리 — 처음엔 「일위대가가 아직 안 선 공종」 목록이었으나, B09 가 넷을 + # 다 세운 뒤로는 **「물량을 못 채운 조각」**을 사유와 함께 낸다. 시험도 함께 옮긴다 + # (「옛 시험이 틀린 계약을 못 박고 있었다」를 오늘 이미 한 번 겪었다). + # ⚠ 사유를 **구조로** 낸다 — 받는 쪽이 「단가 없음」과 「물량 없음」을 갈라야 한다. + # ⭐ 2026-09-09 채워진 자리 — 기초잡석이 **버림 폭에서** 나온다(확정 3차 ②). + # 관측 원단위에 그 줄이 없어도 버림 0.15㎥/m ÷ 0.1 = 폭 1.5m 이고 두께 0.2m 이라 + # 0.30㎥/m 로 선다. ⇒ **묶음 다섯 조각이 다 찼다.** + assert row["composite_not_ready"] is None + + +def test_타설_방식은_설정이_고르고_기본은_레디믹스트() -> None: + from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping, placing_code + + mapping = load_mapping() + assert placing_code(mapping, "ready_mixed") == ("FP-12-01-01", False) + assert placing_code(mapping, "hand_mixed") == ("FP-12-01-03", False) + # 모르는 방식이면 기본으로 떨어지되 **그 사실을 알린다**. + code, used_default = placing_code(mapping, None) + assert code == "FP-12-01-01" and used_default is True + + +# ── 묶음 조각의 부위별 수량 · 철근 갈래 (2026-09-07 3자) ──────────── + + +def 옹벽단위(form: str = "반중력식"): + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit + + return build_unit( + [ + { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 100.0, + "end_m": 110.0, + "options": {"form": form, "height_m": 2.0, "length_m": 10.0}, + } + ], + {"retaining_wall": "옹벽"}, + ) + + +def 조각(row: dict, code_fragment: str) -> dict: + return next(p for p in row["composite_parts"] if code_fragment in str(p.get("code"))) + + +def test_조각마다_수량과_단위가_실릴것() -> None: + """코드만 보내면 받는 쪽이 상세 줄을 못 세운다.""" + row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0] + assert 조각(row, "12-01-01")["quantity"] == pytest.approx( + 15.0 + ) # 콘크리트 1.35 + 버림 0.15, ×10m + assert 조각(row, "12-04")["quantity"] == pytest.approx(6.0) + assert 조각(row, "12-38")["quantity"] == pytest.approx(32.0) + + +def test_철근은_ton_으로_환산될것() -> None: + """⚠ 단가가 원/ton 인데 원단위는 ㎏ 이다 — 안 맞추면 **1000배** 틀린다.""" + row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0] + rebar = 조각(row, "12-03") + assert rebar["unit"] == "ton" + # D13 13.45 + D16 30.42 = 43.87 ㎏/m × 10m = 438.7 ㎏ = 0.4387 ton + assert rebar["quantity"] == pytest.approx(0.4387) + + +def test_조각마다_근거가_실릴것() -> None: + """치수 전개와 관측값이 한 묶음에 섞이므로 조각별로 보여야 한다.""" + row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0] + assert 조각(row, "12-04")["basis_kind"] == "observed" + + +def test_철근_갈래가_원문으로_자동_판정될것() -> None: + """⚠ 거푸집 사용횟수와 같은 자리 — 품셈 12-3 [주]① 이 구조물 예시로 갈라 둔다. + 판정할 수 있는 것을 사람에게 물으면 그것이 곧 미결이 된다. + + ⚠ 판정은 **형식만 보는 순수 함수**라 직접 시험한다. 조각으로만 보면 관측 원단위가 있는 + 형식(반중력식)밖에 못 봐, **판정되는 것만 확인하고 나머지는 한 번도 안 도는** 자리가 된다. + """ + from B08_Quantity.B08_Quantity_Engine_Handoff import rebar_complexity + + for form, expected in (("중력식", "간단"), ("반중력식", "보통"), ("부벽식", "복잡")): + kind, why = rebar_complexity("retaining_wall", {"form": form}) + assert kind == expected + assert "12-3" in why + + +def test_원문에_없는_형식은_지어내지_않을것() -> None: + """캔틸레버식 옹벽은 [주]① 예시에 없다 — 「보통」으로 때우지 않는다.""" + from B08_Quantity.B08_Quantity_Engine_Handoff import rebar_complexity + + kind, why = rebar_complexity("retaining_wall", {"form": "캔틸레버식"}) + assert kind is None + assert "캔틸레버식" in why + + +def test_판정된_갈래가_조각_코드에_붙을것() -> None: + """원단위가 있는 형식에서 코드가 실제로 갈리는지 — 함수 판정과 조각을 잇는 자리.""" + row = build_handoff(unit_quantity_table=옹벽단위("반중력식"))["work_items"][0] + rebar = 조각(row, "12-03") + assert rebar["code"] == "FP-12-03#보통" + assert rebar["kind"] == "보통" + + +def test_원단위가_통째로_없으면_한_줄로_말할것() -> None: + """⚠ 같은 사유를 조각마다 다섯 번 반복하면 **진짜 사유가 묻힌다** + (화면에서 실제로 그렇게 보였다). 캔틸레버식은 관측 원단위 자체가 없다.""" + row = build_handoff(unit_quantity_table=옹벽단위("캔틸레버식"))["work_items"][0] + assert row["composite_parts"] == [] + assert len(row["composite_not_ready"]) == 1 + reason = row["composite_not_ready"][0]["reason"] + assert "자료에 없습니다" in reason + # 「없다」만 말하지 않고 **표에 있는 규격**을 함께 알린다. + assert "반중력식" in reason + + +def test_기초잡석은_버림_폭에서_선다() -> None: + """⭐ 옛 이름은 「물량을 못 채운 조각은 0 이 아니라 사유와 함께」였다. + + 그 조각이 **채워졌다**(2026-09-09 확정 3차 ②). 버림 폭이 곧 잡석다짐 폭이라 + (KCS 34 50 05) 두께 비만 곱하면 관측 원단위 구조물에도 값이 선다 — + 버림 1.5㎥ × (0.2 ÷ 0.1) = 3.0㎥(= 0.30㎥/m × 10m). + 「못 채운 조각은 0 이 아니라 사유와 함께」라는 규칙 자체는 다른 조각 시험이 지킨다. + """ + row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0] + gravel = 조각(row, "12-25") + assert gravel["quantity"] == 3.0 + assert not gravel.get("not_ready") + + +def test_유로폼_갈래도_원문이_정할것() -> None: + """⚠ 「원문이 이미 정해 둠」 **네 번째** — 품셈 12-38-3 [주]④ 「보통: … 옹벽 …」. + (앞선 셋: 거푸집 사용횟수 1-7-1 · 철근 갈래 12-3 [주]① · 밑수는 표 위 본문.)""" + from B08_Quantity.B08_Quantity_Engine_Handoff import euroform_type + + kind, why = euroform_type("retaining_wall") + assert kind == "보통" + assert "12-38-3" in why and "옹벽" in why + assert euroform_type("듣도보도못한시설")[0] is None + + +def test_유로폼_조각_코드에_갈래가_붙을것() -> None: + row = build_handoff(unit_quantity_table=옹벽단위("반중력식"))["work_items"][0] + assert 조각(row, "12-38")["code"] == "FP-12-38#보통" + + +def test_유로폼_사용횟수는_1_7_1_과_다른_자리() -> None: + """⚠ 12-38-1 은 유로폼(강재)의 **잔존율**이고 1-7-1 은 소모성 거푸집 **전용 횟수**다. + 이름이 같아 하나로 이으면 조용히 틀린다.""" + from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table + + note = load_formwork_table().euroform_type["reuse_note"] + assert "다른 자리" in note and "잔존율" in note + + +def test_같은_코드가_지반만_달리해_여러_줄_올_것() -> None: + """⚠ 실물에서 잡힌 모양 — 도자운반 토사·리핑암이 **같은 코드로 두 줄** 온다. + 받는 쪽이 코드로만 묶으면 한 줄이 사라진다.""" + haul = { + "rows": [ + { + "equipment": "dozer", + "ground": "토사", + "volume_m3": 100.0, + "average_distance_m": 40.0, + "in_bill": True, + }, + { + "equipment": "dozer", + "ground": "리핑암", + "volume_m3": 200.0, + "average_distance_m": 40.0, + "in_bill": True, + }, + ] + } + rows = [r for r in build_handoff(haul_table=haul)["work_items"] if r["origin"] == "haul"] + assert len(rows) == 2 + assert {r["work_item_code"] for r in rows} == {"FP-10-11"} # 코드는 같고 + assert {r["ground_class"] for r in rows} == {"토사", "리핑암"} # 지반만 다르다 + + +def test_갈래_키는_내부_공백만_지울것() -> None: + """⚠ 두 창이 같은 규칙을 써야 맞는다 — 한쪽만 고치면 그날로 안 맞는다. + **다른 글자는 손대지 않는다**: 정규화를 넓히면 오늘 아홉 번 겪은 병을 새로 만든다.""" + from B08_Quantity.B08_Quantity_Engine_Handoff import normalize_kind_key + + assert normalize_kind_key("보 통") == "보통" + assert normalize_kind_key("매우 복잡") == "매우복잡" + assert normalize_kind_key("보통") == "보통" + # 다른 글자는 그대로 — 괄호·기호를 지우지 않는다. + assert normalize_kind_key("간단(기초)") == "간단(기초)" + + +def test_원문_문구를_라벨로_함께_실을것() -> None: + """키는 공백을 지우되 **산출근거에는 원문이 보여야** 한다.""" + row = build_handoff(unit_quantity_table=옹벽단위("반중력식"))["work_items"][0] + rebar = 조각(row, "12-03") + assert rebar["code"] == "FP-12-03#보통" + assert rebar["kind_label"] == "보통" + assert "12-3" in rebar["kind_basis"] + + +def test_build_handoff_가_검사_셋을_실제로_부른다(): + """⚠ 「만들어 두고 안 부르면 없는 것과 같다」 — 2026-09-08 ㉘ 에서 둘이 놀고 있었다. + + 호출 여부를 **결과 칸으로** 잰다. 칸이 없으면 안 부른 것이다. + """ + handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) + for key in ("ratio_math_warnings", "material_code_warnings", "bill_flag_warnings"): + assert key in handoff, f"{key} 가 없다 — 검사를 안 불렀다" + + +def test_검사가_실제로_잡는다_일부러_깨뜨려_봄(): + """⚠ 부르기만 하고 못 잡으면 소용없다 — 일부러 깨뜨려 잡히는지 본다.""" + handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) + handoff["materials"].append({"material_name": "가짜자재", "work_item_code": "FP-99-99"}) + assert verify_no_code_on_materials(handoff) == ["가짜자재"] + handoff["work_items"].append({"name": "코드없이내역에선줄", "in_bill": True, "work_item_code": None}) + assert "코드없이내역에선줄" in verify_bill_flags(handoff) + + +# ── 콘크리트 타설 줄 (2026-09-08 ㉙) ───────────────────────────────────────── +# 품은 이 줄, 재료는 자재 쪽. 품셈 12-1-1 표가 직종·품만 주므로 겹치지 않는다. + + +def _콘크리트구조물(components: list[dict]) -> dict: + """⚠ 옹벽은 **묶음으로 서는 종류**라 타설 줄이 안 생긴다(묶음 조각이 셈). + + 그래서 타설 줄 시험은 **묶음이 없는** 콘크리트 구조물로 한다. + """ + return { + "structure_id": "b1", + "type_id": "pipe_inlet_basin", + "name": "집수정", + "length_m": 1.0, + "components": components, + } + + +def test_타설_줄이_실제로_선다() -> None: + """⚠ `placing_code()` 가 시험에서만 불리던 자리 — 이제 `build_handoff` 가 부른다.""" + unit = { + "structures": [ + _콘크리트구조물( + [ + {"name": "콘크리트", "unit": "㎥", "amount": 13.5, "destination": "unit_price"}, + {"name": "이형철근 D13", "unit": "kg", "amount": 134.5, "destination": "material"}, + ] + ) + ] + } + handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method="ready_mixed") + 타설 = [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"] + assert len(타설) == 1 + assert 타설[0]["work_item_code"] == "FP-12-01-01" + assert 타설[0]["quantity"] == 13.5 + # 철근이 있으므로 철근구조물 — 사람이 고르는 값이 아니다. + assert 타설[0]["structure_kind"] == "철근구조물" + assert handoff["placing_notes"] == [] + + +def test_방식을_안_정하면_기본값으로_서되_알린다() -> None: + unit = {"structures": [_콘크리트구조물([{"name": "콘크리트", "unit": "㎥", "amount": 2.0, "destination": "unit_price"}])]} + handoff = build_handoff(unit_quantity_table=unit) + 타설 = next(r for r in handoff["work_items"] if r["name"] == "콘크리트 타설") + assert 타설["work_item_code"] == "FP-12-01-01" # 기본값 = 레디믹스트 + assert 타설["structure_kind"] == "무근구조물" + assert handoff["placing_notes"], "기본값으로 선 사실을 안 알린다" + + +def test_방식을_바꾸면_공종이_갈린다() -> None: + unit = {"structures": [_콘크리트구조물([{"name": "콘크리트", "unit": "㎥", "amount": 2.0, "destination": "unit_price"}])]} + 코드 = {} + for method in ("ready_mixed", "machine_mixed", "hand_mixed"): + handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method=method) + 코드[method] = next( + r["work_item_code"] for r in handoff["work_items"] if r["name"] == "콘크리트 타설" + ) + assert 코드 == { + "ready_mixed": "FP-12-01-01", + "machine_mixed": "FP-12-01-02", + "hand_mixed": "FP-12-01-03", + } + + +def test_콘크리트가_없으면_타설_줄도_없다() -> None: + """0 ㎥ 짜리 빈 줄을 만들지 않는다.""" + unit = {"structures": [_콘크리트구조물([{"name": "야면석", "unit": "ton", "amount": 3.0, "destination": "material"}])]} + handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method="ready_mixed") + assert not [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"] + + +# ── 내역 줄의 축 — 관측 원단위가 「개소당」인 종류 (2026-09-08 ㉕ 실증) ──────── + + +def _집수정(length_m: float) -> dict: + return { + "structure_id": "b1", + "type_id": "pipe_inlet_basin", + "name": "집수정", + "start_m": 50.0, + "end_m": 50.0 + length_m, + "options": { + "inlet_basin_form": "□형(기본형)", + "inlet_basin_material": "콘크리트", + "pipe_diameter_mm": "800", + "length_m": length_m, + }, + } + + +def test_개소_구조물은_연장으로_안_센다() -> None: + """⚠ 연장 2m 짜리 집수정이 「2.000 m」로 나가 **값이 두 배**로 실리던 자리. + + 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 어긋나 있어 아무 시험도 안 잡았다. + """ + handoff = build_handoff(unit_quantity_table=build_unit_table([_집수정(2.0)], {"pipe_inlet_basin": "집수정"})) + 줄 = next(r for r in handoff["work_items"] if r["name"] == "집수정") + assert (줄["unit"], 줄["quantity"]) == ("개소", 1.0) + + +def test_개소_구조물은_연장이_길어도_한_개소() -> None: + 한개 = build_unit_table([_집수정(1.0)], {"pipe_inlet_basin": "집수정"}) + 긴것 = build_unit_table([_집수정(5.0)], {"pipe_inlet_basin": "집수정"}) + for table in (한개, 긴것): + 줄 = next( + r for r in build_handoff(unit_quantity_table=table)["work_items"] if r["name"] == "집수정" + ) + assert (줄["unit"], 줄["quantity"]) == ("개소", 1.0) + + +def test_m당_구조물은_그대로_연장으로_센다() -> None: + """⚠ 좁게 고친다 — 「개소」 때문에 정상 「m」 줄까지 바꾸면 그것이 더 나쁘다.""" + 옹벽 = { + "structure_id": "w1", + "type_id": "retaining_wall", + "name": "옹벽", + "start_m": 100.0, + "end_m": 120.0, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": 20.0}, + } + 줄 = next( + r + for r in build_handoff(unit_quantity_table=build_unit_table([옹벽], {"retaining_wall": "옹벽"}))["work_items"] + if r["name"] == "옹벽" + ) + assert (줄["unit"], 줄["quantity"]) == ("m", 20.0) + + +# ── 준비공·사방공 인계 (2026-09-08, 보조 창 제보) ──────────────────────────── +# ⚠ 줄을 빼면 「빠졌다는 사실조차 안 보인다」 — 받는 쪽에서 「원래 없는 것」과 +# 「우리가 아직 못 내는 것」이 구별되지 않는다. + + +def _준비공표() -> dict: + from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_prep + + slope = { + "face_dressing_fill": 9000.0, + "face_dressing_cut": 6726.9, + "tree_removal_fill": 9000.0, + "tree_removal_cut": 6726.9, + "bench_cut_fill": 9000.0, + } + return build_prep(slope, [], [], 0.15) + + +def test_준비공_값이_서는_줄은_내역에_선다() -> None: + handoff = build_handoff(preparation_table=_준비공표()) + 표토 = next(r for r in handoff["work_items"] if r["name"] == "표토제거") + assert 표토["work_item_code"] == "FP-09-15" + assert 표토["in_bill"] is True + assert 표토["quantity"] > 0 + assert 표토["blocked_kind"] is None + + +def test_못_내는_줄도_사유와_함께_간다() -> None: + """⚠ 여기가 요청의 핵심 — 줄이 아예 안 가면 「우리가 만들 것」에 안 얹힌다.""" + handoff = build_handoff(preparation_table=_준비공표()) + 제근 = next(r for r in handoff["work_items"] if r["name"] == "제근·뿌리다듬기") + # ⭐ 2026-09-09 확정 5차 6번으로 **밑수가 면적 축으로 정해져 값이 선다** — 그래서 이 줄은 + # 더 이상 「못 내는 줄」의 표본이 아니다. 「못 내는 줄도 사유와 함께 간다」는 계약 자체는 + # 아래 표토·부대시설 줄이 지킨다. + assert 제근["in_bill"] is True and 제근["quantity"] > 0 + assert 제근["unit"] == "㎡" # 밑수가 면적 축으로 확정됨(확정 5차 6번) + # ⚠ 사유가 **두 번 바뀐 자리**다 — ㉠ 「입목 본수가 없다」(틀린 말: 본수 축이 아니었다) + # ㉡ 「밑수 단위가 원문에 없다」(사실이었으나 확정으로 닫힘) ㉢ 지금은 **값이 서고** + # 남은 것은 **단가 갈래(임목축적 등급)**뿐이다. 사유는 그 자리에 남는다. + 사유 = 제근["spec_detail"] + " " + str(제근.get("blocked_reason") or "") + assert "임목축적" in 사유 + assert "교차 참조" in 사유 # 건설품셈 3-9-2 를 빌려 쓴다는 표시 + + +def test_다른_표에서_이미_선_줄은_막힌_것이_아니다() -> None: + """벌목은 토공집계에 이미 서 있다 — **막힌 것이 아니라 여기서 세면 안 되는 것**.""" + handoff = build_handoff(preparation_table=_준비공표()) + 벌목 = next(r for r in handoff["work_items"] if r["name"] == "벌목·지장목제거") + assert 벌목["in_bill"] is False + assert 벌목["blocked_kind"] is None, "이중계상 방지 줄을 「막힘」으로 보내면 오해된다" + assert "이미 섬" in 벌목["blocked_reason"] + + +def test_준비공_표를_안_주면_줄도_없다() -> None: + """⚠ 좁게 — 표가 없을 때 빈 줄을 지어내지 않는다.""" + handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()])) + assert not [r for r in handoff["work_items"] if r["origin"] == "preparation"] + + +def test_묶음으로_서는_구조물은_타설_줄을_따로_안_만든다() -> None: + """⚠⚠ 이중계상 — 옹벽 묶음에 `FP-12-01-01 콘크리트 타설` 조각이 이미 있다. + + 2026-09-08 B09 가 「철근이 겹치나」를 묻다 드러난 자리. 철근은 안 겹치고 + (자재는 재료 · 묶음 조각은 품이며 그 일위대가 재료가 0원) **타설이 겹쳤다.** + 묶음이 아직 미확보라 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. + """ + 옹벽 = { + "structure_id": "w1", + "type_id": "retaining_wall", + "name": "옹벽", + "length_m": 10.0, + "components": [ + {"name": "콘크리트", "unit": "㎥", "amount": 13.5, "destination": "unit_price"}, + ], + } + handoff = build_handoff( + unit_quantity_table={"structures": [옹벽]}, concrete_placing_method="ready_mixed" + ) + assert not [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"] + + +def test_묶음이_없는_종류는_그대로_타설_줄이_선다() -> None: + """⚠ 좁게 — 묶음 하나 때문에 정상 타설 줄까지 지우면 그것이 더 나쁘다.""" + handoff = build_handoff( + unit_quantity_table={ + "structures": [ + _콘크리트구조물( + [{"name": "콘크리트", "unit": "㎥", "amount": 2.84, "destination": "unit_price"}] + ) + ] + }, + concrete_placing_method="ready_mixed", + ) + 타설 = [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"] + assert len(타설) == 1 and 타설[0]["quantity"] == 2.84 + + +# ── B군 종단배수 — 연장이 곧 수량인 공종 (2026-09-08 V-3) ──────────────────── + + +def _B군(type_id: str, name: str) -> dict: + return { + "structure_id": "d1", + "type_id": type_id, + "name": name, + "length_m": 40.0, + "components": [], + "notes": [f"{name}의 수량 산출식이 아직 없습니다 — 물량이 서지 않습니다"], + } + + +def test_연장으로_서는_공종은_전개식이_없어도_안_막힌다() -> None: + """⚠⚠ 품셈 밑수가 **1 m** 라 연장이 곧 수량이다 — 원단위 전개가 필요 없다. + + 앞서 「성분이 없으면 전개식 없음」으로 단정해 B09 가 「우리가 만들 것」으로 빼 + **금액이 0** 이었다(실측: 맹암거 40m 이 0원 → 고친 뒤 1,019,685원). + """ + handoff = build_handoff( + unit_quantity_table={"structures": [_B군("underdrain", "맹암거")]} + ) + 줄 = next(r for r in handoff["work_items"] if r["name"] == "맹암거") + assert 줄["work_item_code"] == "FP-12-10" + assert 줄["blocked_kind"] is None, 줄["blocked_reason"] + assert 줄["in_bill"] is True + + +def test_공종코드가_없으면_여전히_막힌다() -> None: + """⚠ 좁게 — 코드도 없는데 안 막힌 것으로 두면 **0원 줄**이 조용히 선다.""" + handoff = build_handoff( + unit_quantity_table={"structures": [_B군("chute", "도수로·산비탈수로")]} + ) + 줄 = next(r for r in handoff["work_items"] if r["name"] == "도수로·산비탈수로") + assert 줄["work_item_code"] is None + assert 줄["blocked_kind"] is not None + assert "도수로·산비탈수로" in handoff["bill_flag_warnings"] + + +def test_연장이_0_이면_코드가_있어도_막힌다() -> None: + """⚠ 좁게 — 수량이 없는데 안 막힌 것으로 두면 0원 줄이 된다.""" + 빈것 = _B군("underdrain", "맹암거") + 빈것["length_m"] = 0.0 + handoff = build_handoff(unit_quantity_table={"structures": [빈것]}) + 줄 = next(r for r in handoff["work_items"] if r["name"] == "맹암거") + assert 줄["blocked_kind"] is not None + + +# ── B군은 연장표로 — 겹친 구간을 두 번 세지 않기 (2026-09-08 두 창 합의) ────── + + +def _연장표(length: float, raw: float, count: int = 2) -> list[dict]: + return [ + { + "type_id": "ditch_ridge", + "group": "B", + "name": "산마루측구", + "count": count, + "length_m": length, + "raw_length_m": raw, + } + ] + + +def test_B군은_종류별_한_줄로_선다() -> None: + handoff = build_handoff(length_table=_연장표(60.0, 80.0)) + 줄 = [r for r in handoff["work_items"] if r["name"] == "산마루측구"] + assert len(줄) == 1 + assert (줄[0]["work_item_code"], 줄[0]["quantity"], 줄[0]["unit"]) == ("FP-12-09-02", 60.0, "m") + assert 줄[0]["spec"] == "2개소" + + +def test_겹친_구간은_합쳐진_값이_수량이_된다() -> None: + """⚠ 구조물별로 세면 겹친 구간을 **두 번** 센다 — 80m 가 아니라 60m 다.""" + 줄 = build_handoff(length_table=_연장표(60.0, 80.0))["work_items"][0] + assert 줄["quantity"] == 60.0 + assert 줄["quantity_gross"] == 80.0 + assert "겹친 20m" in 줄["spec_class_basis"] + + +def test_겹침_설명은_막힌_사유가_아니다() -> None: + """⚠ `blocked_reason` 에 넣으면 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다. + + 2026-09-08 실측에서 실제로 그랬다 — B09 가 「막힘 — 입력 구간 합 80m…」으로 뺐다. + """ + 줄 = build_handoff(length_table=_연장표(60.0, 80.0))["work_items"][0] + assert 줄["blocked_kind"] is None + assert 줄["blocked_reason"] == "" + assert 줄["in_bill"] is True + + +def test_겹치지_않으면_원합을_안_싣는다() -> None: + """⚠ 좁게 — 안 겹쳤는데 `quantity_gross` 를 실으면 「반영률이 곱해진 값」으로 오해된다.""" + 줄 = build_handoff(length_table=_연장표(40.0, 40.0, count=1))["work_items"][0] + assert 줄["quantity_gross"] is None + assert 줄["spec_class_basis"] == "" + + +def test_공종을_못_이은_B군은_사유와_함께_막힌다() -> None: + 표 = [{"type_id": "chute", "group": "B", "name": "도수로·산비탈수로", + "count": 1, "length_m": 40.0, "raw_length_m": 40.0}] + 줄 = build_handoff(length_table=표)["work_items"][0] + assert 줄["work_item_code"] is None + assert 줄["in_bill"] is False + assert "품셈 공종을 아직 못 이었습니다" in 줄["blocked_reason"] + + +# ── B군 산출근거 — 어디부터 어디까지 (2026-09-08 `spans` 받음) ──────────────── + + +def test_구간이_비고에_적히고_측점이_실린다() -> None: + 표 = _연장표(60.0, 80.0) + 표[0]["spans"] = [{"start_m": 80.0, "end_m": 140.0}] + 줄 = build_handoff(length_table=표)["work_items"][0] + assert (줄["station_from"], 줄["station_to"]) == (80.0, 140.0) + assert "구간 80~140m" in 줄["spec_class_basis"] + assert "겹친 20m" in 줄["spec_class_basis"] + + +def test_구간이_여럿이면_처음과_끝을_싣고_전부는_비고에() -> None: + 표 = [ + { + "type_id": "ditch_berm", + "group": "B", + "name": "소단측구", + "count": 3, + "length_m": 30.0, + "raw_length_m": 30.0, + "spans": [ + {"start_m": 10.0, "end_m": 20.0}, + {"start_m": 50.0, "end_m": 60.0}, + {"start_m": 90.0, "end_m": 100.0}, + ], + } + ] + 줄 = build_handoff(length_table=표)["work_items"][0] + assert (줄["station_from"], 줄["station_to"]) == (10.0, 100.0) + # 사이가 빈 것을 숨기지 않는다 — 셋이 다 적혀야 10~100 을 통으로 오해하지 않는다. + assert 줄["spec_class_basis"].count("m") >= 3 + assert "50~60m" in 줄["spec_class_basis"] + + +def test_구간_합이_수량과_같다() -> None: + """⚠ 겹침을 지운 뒤의 목록이라 **그 합이 곧 수량**이다 — 둘이 갈리면 어딘가 틀렸다.""" + 표 = _연장표(60.0, 80.0) + 표[0]["spans"] = [{"start_m": 80.0, "end_m": 140.0}] + 줄 = build_handoff(length_table=표)["work_items"][0] + 합 = sum(s["end_m"] - s["start_m"] for s in 표[0]["spans"]) + assert 합 == 줄["quantity"] + + +def test_구간이_없어도_줄은_선다() -> None: + """⚠ 좁게 — `spans` 가 없다고 줄을 빼면 옛 자료에서 수량이 통째로 사라진다.""" + 줄 = build_handoff(length_table=_연장표(40.0, 40.0, count=1))["work_items"][0] + assert 줄["quantity"] == 40.0 and 줄["in_bill"] is True + assert (줄["station_from"], 줄["station_to"]) == (None, None) diff --git a/resources/tester/test_b08_handoff_contract.py b/resources/tester/test_b08_handoff_contract.py new file mode 100644 index 00000000..1e97677b --- /dev/null +++ b/resources/tester/test_b08_handoff_contract.py @@ -0,0 +1,211 @@ +"""인계 계약 — **내보내는 키가 계약에 적힌 것과 같은지** (2026-09-07 ㉒ 앞). + +⚠ 오늘 이 병이 계약 양쪽에서 하나씩 났다. + · 우리 쪽 — 레지스트리 키(`back_len_cm`)와 엔진이 읽는 키가 달라 **저장값이 안 닿음**. + · 받는 쪽 — 우리가 보낸 `composite_parts`·`quantity_gross` 등을 **안 읽고 버림**. +**「보내는 쪽은 보냈는데 받는 쪽이 안 읽는」 자리는 양쪽 다 조용하다.** + +⇒ 키를 늘릴 때 **계약에 안 적고 늘리면 받는 쪽이 영영 모른다.** 이 시험이 그것을 막는다. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402 + +#: **작업 공종 줄**이 내보내는 칸. 늘릴 때는 계약(8-2)에도 적고 받는 쪽에 알릴 것. +WORK_ITEM_KEYS = { + "work_item_code", + "name", + "spec", + "unit", + "quantity", + "quantity_gross", + "application_ratio_pct", + "application_ratio_breakdown", + "quantity_breakdown", + "ground_class", + "excavation_method", + "haul_distance_m", + "haul_equipment", + "station_from", + "station_to", + "spec_detail", + "composite_parts", + "structure_kind", + "variant_axis", + "variant_value", + # ⚠ 갈래 축이 둘 이상인 자리(돌쌓기 = 뒷길이 × 돌 종류) — 2026-09-08 추가. + "secondary_axes", + "spec_class", + "spec_class_basis", + "blocked_kind", + "blocked_reason", + "composite_not_ready", + "in_bill", + "in_bill_reason", + "origin", +} + +#: **자재 줄**이 내보내는 칸. ⚠ 여기에 `work_item_code` 가 들어가면 안 된다(축이 다르다). +MATERIAL_KEYS = { + "material_name", + "spec", + "unit", + "net_amount", + "total_amount", + "surcharge_pct", + "surcharge_note", + "supply_type", + "install_by", + "source_structure", +} + + +def _rows() -> dict: + unit = build_unit( + [ + { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 100.0, + "end_m": 110.0, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, + } + ], + {"retaining_wall": "옹벽"}, + ) + from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material + + haul = { + "rows": [ + { + "equipment": "dozer", + "ground": "토사", + "volume_m3": 10.0, + "average_distance_m": 40.0, + "in_bill": True, + } + ] + } + summary = {"rows": [{"group": "흙깎기", "item": "토사", "unit": "㎥", "amount": 5.0}]} + return build_handoff( + summary_table=summary, + haul_table=haul, + unit_quantity_table=unit, + material_table=build_material(unit), + ) + + +def test_작업_공종_줄의_칸이_계약과_같을것() -> None: + """⚠ 계약에 없는 칸을 늘리면 받는 쪽이 영영 모른다.""" + for row in _rows()["work_items"]: + extra = set(row) - WORK_ITEM_KEYS + assert not extra, f"계약에 없는 칸: {extra}" + missing = WORK_ITEM_KEYS - set(row) + assert not missing, f"계약에 있는데 안 보내는 칸: {missing}" + + +def test_자재_줄의_칸이_계약과_같을것() -> None: + for row in _rows()["materials"]: + assert set(row) == MATERIAL_KEYS, set(row) ^ MATERIAL_KEYS + + +def test_자재_줄에_공종코드가_없을것() -> None: + """축이 둘이라는 것은 주석이 아니라 계약으로 지킨다.""" + assert "work_item_code" not in MATERIAL_KEYS + for row in _rows()["materials"]: + assert "work_item_code" not in row + + +def test_막힌_까닭이_세_갈래_중_하나일것() -> None: + """받는 쪽이 「입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을 갈라야 한다.""" + from B08_Quantity.B08_Quantity_Engine_Handoff import ( + BLOCKED_FORMULA_MISSING, + BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, + ) + + allowed = {None, BLOCKED_INPUT_MISSING, BLOCKED_UNIT_DATA_MISSING, BLOCKED_FORMULA_MISSING} + for row in _rows()["work_items"]: + assert row["blocked_kind"] in allowed + + +def test_막힌_줄에는_사유가_반드시_있을것() -> None: + """갈래만 있고 사유가 없으면 받는 쪽 화면이 빈다.""" + for row in _rows()["work_items"]: + if row["blocked_kind"]: + assert row["blocked_reason"], row["name"] + + +# ── 갈래 키를 우리가 조립하지 않는다 (2026-09-07 계약 변경) ───────── +# +# ⚠ 품셈 원문이 물결표를 섞어 쓴다 — 13-06-01·02 는 `∼`(U+223C), 13-06-03 은 `~`(U+FF5E). +# 두 창이 각자 키 문자열을 만들면 **글자 하나로 영영 안 맞는다.** +# ⇒ 우리는 **축과 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. + + +def _structure_rows() -> list[dict]: + out = [] + for type_id, options in ( + ("masonry_wet", {"height_m": 1.5, "length_m": 10.0, "back_len_cm": "45"}), + ( + "boulder_masonry", + {"height_m": 2.0, "length_m": 10.0, "stone_cm": "60~80", "bond": "찰쌓기"}, + ), + ): + unit = build_unit( + [ + { + "structure_id": "x", + "type_id": type_id, + "start_m": 0.0, + "end_m": 10.0, + "options": options, + } + ], + {type_id: type_id}, + ) + out.extend(build_handoff(unit_quantity_table=unit)["work_items"]) + return out + + +def test_축과_원본값을_그대로_보낼것() -> None: + rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]} + assert rows["back_len_cm"]["variant_value"] == "45" + assert rows["stone_cm"]["variant_value"] == "60~80" + + +def test_갈래_키_문자열을_코드에_붙이지_말것() -> None: + """⚠ 이 시험이 계약 변경을 지킨다 — `#55cm이하`·`#직경60㎝이상∼80㎝미만` 을 만들면 깨진다. + + ⚠ **메/찰은 예외다** — 그것은 키 표기가 아니라 **공종 자체가 갈리는 의미 판정**이라 + 코드가 `FP-13-06-01`/`-02` 로 통째로 달라진다. + """ + for row in _structure_rows(): + code = str(row["work_item_code"] or "") + assert "#" not in code, f"갈래 키를 코드에 붙였다: {code}" + assert "㎝" not in code and "cm" not in code + + +def test_원본값을_가공하지_말것() -> None: + """물결표·공백을 우리가 손대면 받는 쪽이 원문과 대조를 못 한다.""" + rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]} + raw = rows["stone_cm"]["variant_value"] + assert raw == "60~80" # 물결표 종류를 바꾸거나 「㎝」를 붙이지 않는다 + + +def test_돌_종류를_둘째_축으로_보낼것() -> None: + """⚠ 품셈 13-4 품은 [주]② 로 **깬돌·깬잡석 전용**이고 돌 종류로 갈리는 표는 13-5 다. + + 어느 공종으로 볼지는 사용자 확정 대기라 **공종을 바꾸지 않고 원본값만** 실어 보낸다. + """ + rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]} + axes = rows["back_len_cm"]["secondary_axes"] or [] + assert [item["axis"] for item in axes] == ["stone_kind"] diff --git a/resources/tester/test_b08_haul_double_count.py b/resources/tester/test_b08_haul_double_count.py new file mode 100644 index 00000000..abd3e573 --- /dev/null +++ b/resources/tester/test_b08_haul_double_count.py @@ -0,0 +1,76 @@ +"""운반이 두 축에 실리던 자리 (2026-09-09 실측). + +⚠⚠ 토공집계표는 실무 토적집계 모양이라 「무대·도자운반·덤프운반」을 함께 싣고, + 인계본에는 운반표(FP-10-11·FP-10-12)가 **같은 물량으로 또 실렸다.** + 실측 — 도자 17.389·61.130 · 덤프 37.511·122.417 이 두 축에 각각 있었고 **둘 다 in_bill**. + +⚠ 겨누는 것 넷 + ① 집계 쪽 운반 줄은 **내역 줄이 아니다** + ② 그래도 **값은 남는다** — 검산(무대+도자+덤프 = 총 운반토량)이 그 값을 씀 + ③ 사유가 적힌다 — 「내역 줄은 운반표 쪽」 + ④ 운반표 쪽은 그대로 내역 줄로 선다 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 + +집계 = { + "rows": [ + {"group": "도자운반", "unit": "㎥", "amount": 17.39, "in_bill": True}, + {"group": "덤프운반", "unit": "㎥", "amount": 37.51, "in_bill": True}, + {"group": "무대(종방향유용토)", "unit": "㎥", "amount": 14.76, "in_bill": False}, + {"group": "성토", "unit": "㎥", "amount": 100.0, "in_bill": True}, + ] +} +운반 = { + "rows": [ + { + "equipment": "dozer", + "ground": "토사", + "volume_m3": 15.65, + "natural_m3": 17.39, + "conversion_c": 0.9, + "average_distance_m": 43.66, + "in_bill": True, + } + ] +} + + +def 줄들() -> dict: + rows = build_handoff(summary_table=집계, haul_table=운반)["work_items"] + return {row["name"]: row for row in rows} + + +def test_집계_쪽_운반은_내역_줄이_아니다() -> None: + rows = 줄들() + assert rows["도자운반"]["in_bill"] is False + assert rows["덤프운반"]["in_bill"] is False + + +def test_값은_남는다() -> None: + """⚠ 빼 버리면 검산이 안 된다 — 무대를 그렇게 둔 것과 같은 규칙.""" + assert 줄들()["도자운반"]["quantity"] == 17.39 + + +def test_사유가_적힌다() -> None: + reason = 줄들()["도자운반"]["in_bill_reason"] + assert "내역 줄은 운반표 쪽" in reason + + +def test_운반표_쪽은_그대로_선다() -> None: + row = 줄들()["dozer 운반"] + assert row["in_bill"] is True and row["work_item_code"] == "FP-10-11" + assert row["quantity"] == 17.39 + + +def test_토공_줄은_안_건드린다() -> None: + """⚠ 성토처럼 집계에만 있는 줄은 그대로 내역 줄이다 — 운반 계열만 가른다.""" + assert 줄들()["성토"]["in_bill"] is True diff --git a/resources/tester/test_b08_haul_inputs.py b/resources/tester/test_b08_haul_inputs.py new file mode 100644 index 00000000..b05f61ec --- /dev/null +++ b/resources/tester/test_b08_haul_inputs.py @@ -0,0 +1,209 @@ +"""유토곡선에 넘길 구조물 몫 — 채집석 공제 · 구조물 잔토 (2026-09-08). + +⚠ 겨누는 것 다섯 + ① 둘 다 **양수**로 낸다 — 빼고 더하는 것은 받는 쪽(B06) 몫 + ② ⚠ 「아직 안 옴(None)」과 「없음(0)」을 가른다 + ③ **측점별로도** 낸다 — 총량만 주면 잔량 비례로 흩어져 운반거리가 틀어진다 + ④ 구조물이 구간이라 **가운데 측점**을 자리로 본다 + ⑤ 칸 이름이 받는 쪽과 같은 낱말이다 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_HaulInputs import ( # noqa: E402 + STRUCTURE_SPOIL_KEY, + STRUCTURE_SPOIL_POINTS_KEY, + haul_inputs, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + COLLECTED_STONE_KEY, + build_table, +) + + +def 표(*ranges: tuple[float, float]) -> dict: + return build_table( + [ + { + "structure_id": f"s{index}", + "type_id": "masonry_wet", + "start_m": start, + "end_m": end, + "options": { + "height_m": 2.5, + "length_m": end - start, + "back_len_cm": 45, + "foundation": "기초유", + }, + } + for index, (start, end) in enumerate(ranges) + ] + ) + + +def test_둘_다_양수로_낸다() -> None: + got = haul_inputs(표((0.0, 10.0))) + assert got[COLLECTED_STONE_KEY] > 0 + assert got[STRUCTURE_SPOIL_KEY] > 0 + + +def test_아직_안_옴과_없음을_가른다() -> None: + """⚠ 0 으로 눅이면 받는 쪽이 「없다」와 「못 받았다」를 못 가른다.""" + 빈값 = haul_inputs(None) + assert 빈값[STRUCTURE_SPOIL_KEY] is None + assert 빈값[COLLECTED_STONE_KEY] is None + assert 빈값[STRUCTURE_SPOIL_POINTS_KEY] == [] + + +def test_측점별로도_낸다() -> None: + got = haul_inputs(표((0.0, 10.0), (100.0, 110.0))) + points = got[STRUCTURE_SPOIL_POINTS_KEY] + assert [row["chainage_m"] for row in points] == [5.0, 105.0] + assert abs(sum(row["spoil_m3"] for row in points) - got[STRUCTURE_SPOIL_KEY]) < 0.01 + + +def test_측점_차례로_선다() -> None: + """⚠ 받는 쪽이 잔량에 얹을 때 차례가 어긋나면 운반거리가 틀어진다.""" + points = haul_inputs(표((100.0, 110.0), (0.0, 10.0)))[STRUCTURE_SPOIL_POINTS_KEY] + assert [row["chainage_m"] for row in points] == [5.0, 105.0] + + +def test_칸_이름이_받는_쪽과_같다() -> None: + assert STRUCTURE_SPOIL_KEY == "structure_spoil_m3" + assert COLLECTED_STONE_KEY == "collected_stone_deduction_m3" + + +def test_지반_갈래를_함께_낸다() -> None: + """⚠ 새 판정이 아니라 **구조물터파기가 쓰는 그 값**이다 — 두 벌로 짜면 갈린다.""" + unit = build_table( + [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": { + "height_m": 2.5, + "length_m": 10.0, + "back_len_cm": 45, + "foundation": "기초유", + }, + } + ], + None, + None, + {0.0: "ripping_rock", 10.0: "ripping_rock"}, + ) + point = haul_inputs(unit)[STRUCTURE_SPOIL_POINTS_KEY][0] + assert point["ground_type"] == "ripping_rock" + assert point["ground_label"] == "암절취" + assert "암절취" in point["ground_basis"] + + +def test_섞인_구조물은_모름으로_보낸다() -> None: + """⚠ 터파기에서 다수결로 안 고른 그 규칙 그대로 — 받는 쪽이 「모르는 몫」으로 드러낸다.""" + unit = build_table( + [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": { + "height_m": 2.5, + "length_m": 10.0, + "back_len_cm": 45, + "foundation": "기초유", + }, + } + ], + None, + None, + {0.0: "soil", 10.0: "blasting_rock"}, + ) + point = haul_inputs(unit)[STRUCTURE_SPOIL_POINTS_KEY][0] + assert point["ground_type"] is None + assert "섞여" in point["ground_basis"] + + +def test_상태를_값으로_적어_보낸다() -> None: + """⚠⚠ 유토곡선 잔량은 **다짐상태**인데 이 값은 **자연상태**다 — 그냥 더하면 섞인다. + + 말로만 두면 다음 사람이 못 본다. 총량에도, 측점마다도 상태를 적는다. + """ + from B08_Quantity.B08_Quantity_Engine_HaulInputs import ( + VOLUME_BASIS_KEY, + VOLUME_BASIS_NATURAL, + ) + + got = haul_inputs(표((0.0, 10.0))) + assert got[VOLUME_BASIS_KEY] == VOLUME_BASIS_NATURAL == "natural" + assert all(row[VOLUME_BASIS_KEY] == "natural" for row in got[STRUCTURE_SPOIL_POINTS_KEY]) + # 빈 값일 때도 칸은 있다 — 받는 쪽이 「상태를 모른다」와 「값이 없다」를 안 헷갈리게. + assert haul_inputs(None)[VOLUME_BASIS_KEY] == "natural" + + +def test_채집석을_갈래별로_낸다() -> None: + """⚠ 축을 맞추려면 갈래가 있어야 한다 — 받는 쪽이 ×C 로 다짐 축에 맞춰 뺀다.""" + from B08_Quantity.B08_Quantity_Engine_HaulInputs import ( + COLLECTED_STONE_BY_GROUND_KEY, + COLLECTED_STONE_UNKNOWN_KEY, + ) + + unit = build_table( + [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, + } + ], + None, + None, + {0.0: "ripping_rock", 10.0: "ripping_rock"}, + ) + got = haul_inputs(unit) + assert got[COLLECTED_STONE_BY_GROUND_KEY]["ripping_rock"] > 0 + assert got[COLLECTED_STONE_UNKNOWN_KEY] == 0.0 + # 갈래별 합이 총량과 같다 — 어느 쪽으로도 새지 않는다. + assert abs(sum(got[COLLECTED_STONE_BY_GROUND_KEY].values()) - got[COLLECTED_STONE_KEY]) < 0.01 + + +def test_갈래를_못_가른_몫은_따로_낸다() -> None: + """⚠ 계수가 없으므로 환산하지 않는다 — 받는 쪽이 그 사실을 알아야 한다.""" + from B08_Quantity.B08_Quantity_Engine_HaulInputs import ( + COLLECTED_STONE_BY_GROUND_KEY, + COLLECTED_STONE_UNKNOWN_KEY, + ) + + unit = build_table( + [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, + } + ], + None, + None, + {0.0: "soil", 10.0: "blasting_rock"}, + ) + got = haul_inputs(unit) + assert got[COLLECTED_STONE_BY_GROUND_KEY] == {} + assert got[COLLECTED_STONE_UNKNOWN_KEY] > 0 + + +def test_원문이_없다는_사실을_값과_함께_보낸다() -> None: + """⚠ 벽 입적 ↔ 원바닥 암 관계를 정한 원문이 없다 — 받는 쪽이 그 한계를 알아야 한다.""" + basis = haul_inputs(표((0.0, 10.0)))["collected_stone_basis"] + assert "원문이 없음" in basis and "실무 시트는 그냥 뺌" in basis diff --git a/resources/tester/test_b08_haul_state.py b/resources/tester/test_b08_haul_state.py new file mode 100644 index 00000000..333542e1 --- /dev/null +++ b/resources/tester/test_b08_haul_state.py @@ -0,0 +1,84 @@ +"""운반 줄은 자연상태로 선다 (2026-09-09). + +⚠⚠ **두 자가 섞여 있던 자리다.** 유토곡선은 **다짐상태**로 쌓고(운반거리를 그 기준으로 재야 + 맞는다) **내역서에 오르는 수량은 자연상태**다 — + `config_system_design` 5-4-3 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, + **내역서에 적용하는 수량은 자연상태로 한다**」. + +⚠ 겨누는 것 넷 + ① 되돌린 값(`natural_m3`)이 오면 **그 값으로 선다** + ② 쓴 계수를 근거에 적는다 — 되짚을 수 있어야 한다 + ③ ⚠ 되돌릴 계수가 없으면(갈래 못 붙임) **다짐 그대로 두고 사유를 낸다** — 토사 계수로 + 눅이면 근거 없이 금액이 움직인다 + ④ **거리는 안 바뀐다** — 거리는 다짐 기준으로 재는 것이 맞다 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 + + +def 표(**extra: object) -> dict: + row = { + "equipment": "dump_truck", + "ground": "리핑암", + "volume_m3": 140.78, + "average_distance_m": 318.6, + "in_bill": True, + } + row.update(extra) + return {"rows": [row]} + + +def 줄(**extra: object) -> dict: + rows = build_handoff(haul_table=표(**extra))["work_items"] + return next(row for row in rows if row["name"] == "dump_truck 운반") + + +def test_되돌린_값이_오면_그것으로_선다() -> None: + row = 줄(natural_m3=122.42, conversion_c=1.15) + assert row["quantity"] == 122.42 + + +def test_쓴_계수가_근거에_적힌다() -> None: + row = 줄(natural_m3=122.42, conversion_c=1.15) + assert "÷ C 1.15" in row["spec_detail"] and "자연상태" in row["spec_detail"] + + +def test_되돌릴_계수가_없으면_다짐_그대로_두고_사유() -> None: + """⚠ 토사 계수로 눅이면 근거 없이 금액이 움직인다.""" + row = 줄() + assert row["quantity"] == 140.78 + assert "다짐상태 그대로" in row["spec_detail"] + + +def test_거리는_안_바뀐다() -> None: + """거리는 다짐 기준으로 재는 것이 맞다 — 상태를 되돌려도 그대로다.""" + assert 줄(natural_m3=122.42, conversion_c=1.15)["haul_distance_m"] == 318.6 + + +def test_갈래를_통로로_보낸다() -> None: + """⚠⚠ 운반 단가가 지반 갈래로 갈리는데 `spec` 문자열만 보내면 받는 쪽이 못 고른다. + + 「버림」에서 275,584원이 사라졌던 그 자리와 같다 — 갈래는 `variant_value` 한 통로로만. + """ + row = 줄(natural_m3=122.42, conversion_c=1.15) + assert row["variant_axis"] == "ground_class" + assert row["variant_value"] == "리핑암" + # ⚠ 이름은 우리 갈래 그대로 — 일위대가의 「파쇄암」과는 인계본 별칭이 이어 준다. + assert row["spec"] == "리핑암" + + +def test_갈래가_없는_줄은_칸이_비어_있다() -> None: + """무대처럼 갈래가 없는 자리에 빈 문자열을 넣지 않는다 — `None` 이라야 「없음」이다.""" + rows = build_handoff(haul_table={"rows": [{"equipment": "dozer", "volume_m3": 10.0}]})[ + "work_items" + ] + row = next(r for r in rows if r["name"] == "dozer 운반") + assert row["variant_axis"] is None and row["variant_value"] is None diff --git a/resources/tester/test_b08_haul_summary.py b/resources/tester/test_b08_haul_summary.py new file mode 100644 index 00000000..a6f4f954 --- /dev/null +++ b/resources/tester/test_b08_haul_summary.py @@ -0,0 +1,307 @@ +"""운반 가중평균 검사 — PLAN 8-3·8-7 ㉡. + +실무는 (운반수단 × 지반유형)별 **가중평균 1개**를 내역에 올린다. 단순평균이 아니다. +무대는 값은 내되 **내역 줄이 되지 않는다**(품셈 1-2-7). +""" + +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_HaulSummary import ( # noqa: E402 + build_table, + check_against_plan, + summary_input_rows, +) + + +def plan() -> dict: + """띠 셋 + 장거리 이동 하나. 지반유형은 `HaulPlan` 이 이미 안분해 둔 값이다.""" + return { + "blocks": [ + { + "bands": [ + { + "equipment": "free_haul", + "haul_distance_m": 12.0, + "haul_from_m": 0.0, + "haul_to_m": 12.0, + "ea_m3": 800.0, + "rr_m3": 0.0, + "br_m3": 0.0, + }, + { + "equipment": "dozer", + "haul_distance_m": 40.0, + "haul_from_m": 20.0, + "haul_to_m": 60.0, + "ea_m3": 600.0, + "rr_m3": 400.0, + "br_m3": 0.0, + }, + { + "equipment": "dozer", + "haul_distance_m": 50.0, + "haul_from_m": 60.0, + "haul_to_m": 110.0, + "ea_m3": 400.0, + "rr_m3": 0.0, + "br_m3": 0.0, + }, + ] + } + ], + "transfers": [ + { + "equipment": "dump_truck", + "haul_distance_m": 300.0, + "from_m": 100.0, + "to_m": 400.0, + "ea_m3": 1000.0, + "rr_m3": 0.0, + "br_m3": 500.0, + } + ], + "hauled_m3": 2200.0, + "transferred_m3": 1500.0, + } + + +def test_가중평균이지_단순평균이_아님() -> None: + """도자 토사 = 600㎥@40m + 400㎥@50m → (600·40+400·50)/1000 = 44m.""" + rows = build_table(plan())["rows"] + dozer_soil = next(r for r in rows if r["equipment"] == "dozer" and r["ground"] == "토사") + assert dozer_soil["volume_m3"] == pytest.approx(1000.0) + assert dozer_soil["average_distance_m"] == pytest.approx(44.0) + assert dozer_soil["average_distance_m"] != pytest.approx(45.0) # 단순평균이면 45 + + +def test_지반유형별로_갈림() -> None: + """`HaulPlan` 이 안분해 둔 토사·리핑암·발파암을 그대로 쓴다 — 다시 판정하지 않는다.""" + rows = build_table(plan())["rows"] + dozer = {r["ground"] for r in rows if r["equipment"] == "dozer"} + assert dozer == {"토사", "리핑암"} + dump = {r["ground"] for r in rows if r["equipment"] == "dump_truck"} + assert dump == {"토사", "발파암"} + + +def test_무대는_내역줄이_아님() -> None: + """품셈 1-2-7 — 소운반 20m 는 품에 포함. 인력운반 10-6 도 「초과분」이다.""" + table = build_table(plan()) + free = next(r for r in table["rows"] if r["equipment"] == "free_haul") + assert free["in_bill"] is False + assert free["volume_m3"] == pytest.approx(800.0) # 값은 낸다 + assert all(r["in_bill"] for r in table["rows"] if r["equipment"] != "free_haul") + + +def test_내역줄_개수는_무대를_뺀_수() -> None: + table = build_table(plan()) + assert table["bill_row_count"] == len([r for r in table["rows"] if r["in_bill"]]) + assert table["bill_row_count"] == 4 # 도자 토사·리핑암 · 덤프 토사·발파암 + + +def test_근거줄이_함께_나옴() -> None: + """어느 구간이 그 평균을 만들었는지 되짚을 수 있어야 한다.""" + table = build_table(plan()) + legs = table["legs"] + assert len(legs) == 6 # 무대1 + 도자3 + 덤프2 + assert {leg["source"] for leg in legs} == {"band", "transfer"} + dozer_legs = [leg for leg in legs if leg["equipment"] == "dozer" and leg["ground"] == "토사"] + assert sorted(leg["distance_m"] for leg in dozer_legs) == [40.0, 50.0] + + +def test_줄_순서가_늘_같음() -> None: + """내역 줄 순서가 매번 달라지면 대조를 못 한다.""" + first = [(r["equipment"], r["ground"]) for r in build_table(plan())["rows"]] + second = [(r["equipment"], r["ground"]) for r in build_table(plan())["rows"]] + assert first == second + assert first[0][0] == "free_haul" # 무대가 맨 앞 + + +def test_검산_무대를_넣어야_합이_맞음() -> None: + """무대를 안 내면 이 대조가 죽는다 — 그래서 값은 내고 내역 줄만 뺀다.""" + table = build_table(plan()) + check = check_against_plan(table, plan()) + assert check.hauled_total_m3 == pytest.approx(3700.0) # 800+1000+400+1000+500 + assert check.plan_total_m3 == pytest.approx(3700.0) + assert check.difference_m3 == pytest.approx(0.0) + assert check.details["free_haul"] == pytest.approx(800.0) + + +def test_집계표_입력으로_줄임() -> None: + """토공집계표는 근거 줄을 안 쓴다 — 내역 줄만 넘긴다. + + ⚠ 2026-09-09 에 칸이 **둘 늘었다** — 다짐·자연 두 상태를 함께 넘긴다 + (`natural_m3`·`volume_basis`). 내역서 수량은 자연상태라(config 5-4-3) 받는 쪽이 + 어느 상태인지 스스로 봐야 한다. 근거 줄(`legs`)을 안 넘긴다는 계약은 그대로다. + """ + rows = summary_input_rows(build_table(plan())) + assert all( + set(row) + == { + "equipment", + "ground", + "volume_m3", + "average_distance_m", + "natural_m3", + "volume_basis", + # 쓴 계수도 함께 온다 — 받는 쪽이 되짚을 수 있어야 한다. + "conversion_c", + } + for row in rows + ) + assert len(rows) == 5 # 무대 포함(집계표에는 오른다) + + +def test_계획이_비면_빈_표() -> None: + table = build_table(None) + assert table["rows"] == [] + assert table["legs"] == [] + + +# ── 저장 정본 모양 (2026-09-07 실증에서 잡은 자리) ────────────────── +# +# ⚠ 정본에 저장되는 것은 유토곡선 한 벌(`mass_haul`)이고 **배분은 그 안의 `haul_plan`** 이다. +# 바깥 껍데기를 그대로 넘기면 `blocks` 를 못 찾아 **운반 표가 영영 0줄**이 된다. +# [확정] 전에는 어차피 빈 표라 화면에서 티가 안 나던 자리다. + + +def 저장정본() -> dict: + """서버 계산이 실제로 내는 모양 — `mass_haul` 바깥에 `haul_plan` 이 들어 있다.""" + return { + "basis": "cross", + "cut_natural_m3": 5000.0, + "haul_plan": { + "hauled_m3": 3724.0, + "blocks": [ + { + "bands": [ + { + "equipment": "free_haul", + "haul_distance_m": 10.0, + "ea_m3": 3.9, + "rr_m3": 0, + "br_m3": 0, + }, + { + "equipment": "dozer", + "haul_distance_m": 40.0, + "ea_m3": 7.8, + "rr_m3": 0, + "br_m3": 0, + }, + { + "equipment": "dump_truck", + "haul_distance_m": 288.12, + "ea_m3": 3712.29, + "rr_m3": 0, + "br_m3": 0, + }, + ] + } + ], + "transfers": [], + }, + } + + +def test_바깥_껍데기를_그대로_주면_빈_표가_된다() -> None: + """⚠ 이 시험이 그 사고를 못 박는다 — 빈 표는 「계획이 없다」와 구별이 안 된다.""" + assert build_table(저장정본())["rows"] == [] + + +def test_haul_plan_을_벗겨_주면_세_줄이_선다() -> None: + table = build_table(저장정본()["haul_plan"]) + equipment = [ + row.equipment if hasattr(row, "equipment") else row["equipment"] for row in table["rows"] + ] + assert equipment == ["free_haul", "dozer", "dump_truck"] + assert table["bill_row_count"] == 2 # 무대는 내역 줄이 아니다 + + +# ── 암 운반 (2026-09-07 실증) ──────────────────────────────────────── +# +# 앞선 실증에서는 토사만 나와 **(수단 × 지반유형) 갈래가 실물로 갈리는 것을 못 봤다**. +# 암이 유용토에 실리는 노선으로 다시 돌려 6줄로 갈리는 것과 보정계수가 물리는 것을 확인했고, +# 그 모양을 여기 못 박는다. + + +def 암_운반계획() -> dict: + """서버 계산이 실제로 낸 모양(리핑암 노선) — 띠마다 지반별 물량이 함께 온다.""" + return { + "hauled_m3": 7179.0, + "blocks": [ + { + "bands": [ + { + "equipment": "free_haul", + "haul_distance_m": 10.0, + "ea_m3": 14.16, + "rr_m3": 18.09, + "br_m3": 0.0, + }, + { + "equipment": "dozer", + "haul_distance_m": 40.0, + "ea_m3": 28.32, + "rr_m3": 36.19, + "br_m3": 0.0, + }, + { + "equipment": "dump_truck", + "haul_distance_m": 439.92, + "ea_m3": 988.08, + "rr_m3": 6094.15, + "br_m3": 0.0, + }, + ] + } + ], + "transfers": [], + } + + +def test_수단과_지반유형으로_갈려_여섯_줄이_섬() -> None: + table = build_table(암_운반계획()) + pairs = [(row["equipment"], row["ground"]) for row in table["rows"]] + assert ("dump_truck", "리핑암") in pairs + assert ("free_haul", "리핑암") in pairs + assert len(table["rows"]) == 6 + + +def test_암도_무대는_내역에_안_섬() -> None: + """지반이 암이어도 무대는 품에 포함이다 — 지반유형이 그 규칙을 바꾸지 않는다.""" + table = build_table(암_운반계획()) + free = [row for row in table["rows"] if row["equipment"] == "free_haul"] + assert len(free) == 2 # 토사·리핑암 + assert all(not row["in_bill"] for row in free) + assert table["bill_row_count"] == 4 + + +def test_지반별_합이_총_운반토량과_맞을것() -> None: + table = build_table(암_운반계획()) + total = sum(row["volume_m3"] for row in table["rows"]) + assert total == pytest.approx(7179.0, abs=0.02) # 띠 값 자체의 반올림 몫 + + +def test_보정계수가_다짐환산에_물릴것() -> None: + """⚠ 실증 산수 — 리핑암 1.15 · 발파암 1.30 이 그대로 곱해진다. + + 자연토량 토사 1,240 · 암 5,560 인 같은 노선에서 + 리핑암: 1,240×0.9 + 5,560×1.15 = 1,116 + 6,394 = 7,510 + 발파암: 1,240×0.9 + 5,560×1.30 = 1,116 + 7,228 = 8,344 + 서버 계산이 낸 값과 자릿수까지 맞았다. 계수가 바뀌면 이 시험이 깨진다. + """ + from config.config_system_design import EARTHWORK_CONVERSION_FACTORS as F + + assert F["soil"]["compacted"] == 0.9 + assert F["ripping_rock"]["compacted"] == 1.15 + assert F["blasting_rock"]["compacted"] == 1.30 + assert 1240 * 0.9 + 5560 * 1.15 == pytest.approx(7510.0) + assert 1240 * 0.9 + 5560 * 1.30 == pytest.approx(8344.0) diff --git a/resources/tester/test_b08_master_basis_person.py b/resources/tester/test_b08_master_basis_person.py new file mode 100644 index 00000000..3cdcf61b --- /dev/null +++ b/resources/tester/test_b08_master_basis_person.py @@ -0,0 +1,79 @@ +"""마스터 밑수 판독 — 「인」과 「칸 이어붙임」 두 자리 (2026-09-09). + +⚠⚠ **「인」은 품의 단위이지 공종 밑수가 아니다.** 원문 대조로 넷이 오독으로 드러났다 — + 8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」(그 「인」은 **소요인력**) · + 13-2-4 야면석 채집 「(단위: 인 당)」(표 안이 **㎡당·㎥당** 두 줄). + ⚠ 넓게 「인」을 버리면 **2-2-5 천공기가 사라진다** — 「천공인부 **1인당** 1대」는 숫자가 붙었다. + ⇒ **숫자가 붙었을 때만** 인정한다. + +⚠⚠ **칸을 이어 붙여 읽지 않는다.** 앞 칸의 **값**과 뒤 칸의 **단위**가 붙어 없는 밑수가 생겼다 — + 13-2-4 … 0.28 | **0.36** / **㎥당** | 0.60 … ⇒ 「0.36㎥당」(원문에 없는 값) + 5-19-2 15 | **30**(표토두께 ㎝) / **㎥ 당** | … ⇒ 「30㎥당」(두께를 밑수로 읽음) +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Build_WorkItemMaster import ( # noqa: E402 + detect_basis, + person_basis_ok, +) + +MASTER = json.loads( + (ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json").read_text( + encoding="utf-8" + ) +) + + +def 밑수(code: str) -> list[tuple]: + for item in MASTER["work_items"]: + if item["work_item_code"] == code: + return [ + (t.get("basis_unit"), t.get("basis_quantity")) for t in (item.get("tables") or []) + ] + return [] + + +def test_숫자가_붙어야_인을_인정한다() -> None: + assert person_basis_ok("1", "인") is True + assert person_basis_ok("", "인") is False + assert person_basis_ok(None, "인") is False + # 「인」이 아닌 단위는 숫자가 없어도 그대로 — 종전 동작을 안 바꾼다. + assert person_basis_ok("", "㎥") is True + + +def test_방제_둘은_밑수가_안_선다() -> None: + """「(단위 : 인)」은 소요인력이지 밑수가 아니다 — 미확보가 정직하다.""" + assert all(unit != "인" for unit, _ in 밑수("FP-08-06-02")) + assert all(unit != "인" for unit, _ in 밑수("FP-08-06-03")) + + +def test_천공기는_그대로_선다() -> None: + """⚠ 넓게 버리면 사라지는 자리 — 「천공인부 1인당 1대」는 숫자가 붙었다.""" + assert ("인", 1.0) in 밑수("FP-02-02-05") + + +def test_칸을_이어_붙여_읽지_않는다() -> None: + """앞 칸 값 + 뒤 칸 단위 = 없는 밑수. 두 실례를 그대로 잰다.""" + table = { + "headers": ["뒷 길 이(㎝)", "25", "35", "45", "55", "60", ""], + "rows": [["인 부", "㎡당", "0.11", "0.17", "0.22", "0.28", "0.36"], ["㎥당", "0.60"]], + } + assert detect_basis(table) == (None, None) + 두께표 = { + "headers": ["구 분", "직 종", "표 토 두 께(㎝)", "비고", ""], + "rows": [["15", "30", "", "", ""], ["㎥ 당", "보통인부", "0.14", "0.11", ""]], + } + assert 두께표 and detect_basis(두께표) == (None, None) + + +def test_한_칸_안의_밑수는_그대로_읽는다() -> None: + """「100㎥당」처럼 한 칸에 다 있는 것은 종전대로 선다.""" + assert detect_basis({"headers": ["(단위: 100㎥당)"], "rows": []}) == (100.0, "㎥") diff --git a/resources/tester/test_b08_material_concrete.py b/resources/tester/test_b08_material_concrete.py new file mode 100644 index 00000000..8866d941 --- /dev/null +++ b/resources/tester/test_b08_material_concrete.py @@ -0,0 +1,100 @@ +"""콘크리트를 자재 축에 세움 · 할증 이름 잇기 (2026-09-09 확정 3차 ⑥). + +⚠⚠ **재료비가 통째로 빠져 있던 자리다.** 타설 줄(품셈 12-1)은 **품만** 주고 재료를 안 준다 + (서브 일위대가도 재료 0원). 콘크리트를 자재 축에 안 보내면 레미콘 값이 어디에도 없다. + +⚠ 겨누는 것 여섯 + ① 콘크리트 셋이 자재총괄에 선다 + ② 배합은 여전히 분해 안 함(㉢) — 「콘크리트 ㎥」에서 멈춘다 + ③ ⚠ 타설 줄과 **겹치지 않는다** — 그쪽은 품, 이쪽은 재료 + ④ 할증은 **레미콘일 때만** 붙는다 — 비빔은 시멘트·골재가 각각 할증됨 + ⑤ 이형철근 D13·D16 은 할증표 「이형철근」 줄로 이어진다(품셈 1-3-1, 규격 안 가림) + ⑥ ⚠ 이름을 **바꾸지 않는다** — 이름만 잇는다(리핑암↔파쇄암과 같은 처방) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 + build_table as build_material, +) +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import surcharge_lookup_name # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + + +def 단위표() -> dict: + return build_table( + [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": { + "height_m": 2.5, + "length_m": 10.0, + "back_len_cm": 45, + "foundation": "기초유", + }, + } + ] + ) + + +def 자재(method: str | None = None) -> dict: + return { + row["name"]: row for row in build_material(단위표(), concrete_placing_method=method)["rows"] + } + + +def test_콘크리트가_자재총괄에_선다() -> None: + rows = 자재() + assert rows["채움콘크리트"]["net_amount"] > 0 + assert rows["버림콘크리트"]["net_amount"] > 0 + + +def test_배합은_분해하지_않는다() -> None: + """⚠ 시멘트·모래·자갈로 쪼개면 B09 일위대가와 두 배가 된다(㉢).""" + rows = 자재() + assert not ({"시멘트", "모래", "자갈"} & set(rows)) + + +def test_타설_줄과_겹치지_않는다() -> None: + """타설은 품, 자재는 재료 — 둘 다 서되 같은 것을 두 번 세지 않는다.""" + rows = build_handoff(unit_quantity_table=단위표())["work_items"] + placing = [row for row in rows if row["name"] == "콘크리트 타설"] + assert len(placing) == 1 + assert all(row["name"] != "채움콘크리트" for row in rows) + + +def test_할증은_레미콘일_때만_붙는다() -> None: + """⚠ 비빔은 시멘트·골재가 각각 할증되는 자리라 레미콘 할증을 붙이면 틀린다.""" + 레미콘 = 자재("ready_mixed")["채움콘크리트"] + 비빔 = 자재("machine_mixed")["채움콘크리트"] + # ⚠ 「안 정함」은 비빔이 아니다 — 타설 줄이 기본값(레디믹스트)으로 도는데 할증만 + # 미확보로 두면 같은 프로젝트에서 두 값이 어긋난다(실화면에서 걸린 자리). + 안정함 = 자재(None)["채움콘크리트"] + assert 안정함["surcharge_pct"] is not None + assert "기본값" in 안정함["note"] + assert 레미콘["surcharge_pct"] is not None + assert 비빔["surcharge_pct"] is None + assert "레미콘이 아니라" in 비빔["note"] or "레미콘이 아니라" in str(비빔) + + +def test_이형철근은_규격을_안_가린다() -> None: + """품셈 1-3-1 「이형철근」 줄 하나가 D13·D16 을 다 받는다.""" + assert surcharge_lookup_name("이형철근 D13", None)[0] == "이형철근" + assert surcharge_lookup_name("이형철근 D16", None)[0] == "이형철근" + + +def test_이름을_바꾸지_않는다() -> None: + """⚠ 줄 이름이 바뀌면 타설 줄·묶음 조각이 성분을 못 찾는다 — 찾을 때만 다른 이름을 쓴다.""" + rows = 자재("ready_mixed") + assert "레미콘" not in rows + assert rows["채움콘크리트"]["name"] == "채움콘크리트" diff --git a/resources/tester/test_b08_material_summary.py b/resources/tester/test_b08_material_summary.py new file mode 100644 index 00000000..5966bddf --- /dev/null +++ b/resources/tester/test_b08_material_summary.py @@ -0,0 +1,291 @@ +"""자재 총괄표 검사 — PLAN 8-2·8-3·8-7. + +이 일감의 위험은 계산이 아니라 **할증을 두 번 붙이는 것**과 **모르는 값을 0 으로 넘기는 것**이다. + ㉠ 할증은 여기 한 번뿐 — 앞 단계가 붙였으면 경고가 떠야 한다. + · 표에 없는 자재를 0 % 로 조용히 넘기면 빠뜨린 것과 구별이 안 된다. + · 관급/사급은 발주 결정이라 지어내지 않는다 — 안 정하면 「미분류」로 드러난다. + · 할증 전·후를 둘 다 남긴다 — 하나만 넘기면 B09 가 역산한다. +""" + +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_MaterialSummary import ( # noqa: E402 + INSTALL_BY_CONTRACTOR, + INSTALL_BY_OWNER, + NOTE_INCLUDED, + NOTE_INSTALL_BY_MISSING, + NOTE_RATE_MISSING, + SUPPLY_CONTRACTOR, + SUPPLY_OWNER, + SUPPLY_UNKNOWN, + SurchargeTable, + build_table, + load_surcharge_table, + verify_single_surcharge, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table # noqa: E402 + + +def 성분(name: str, unit: str, amount: float, destination: str = "material", **extra) -> dict: + return {"name": name, "unit": unit, "amount": amount, "destination": destination, **extra} + + +def 원단위표(*components: dict, surcharge_applied: bool = False) -> dict: + """원단위 엔진이 내는 모양 그대로.""" + return { + "structures": [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "name": "돌쌓기(찰)", + "components": list(components), + } + ], + "surcharge_applied": surcharge_applied, + } + + +def 줄(table: dict, name: str) -> dict: + return next(row for row in table["rows"] if row["name"] == name) + + +# ── ㉠ 할증은 여기 한 번뿐 ────────────────────────────────────────── + + +def test_앞단계가_붙였으면_경고() -> None: + assert verify_single_surcharge({"surcharge_applied": True}) + assert not verify_single_surcharge({"surcharge_applied": False}) + + +def test_경고가_표에_실릴것() -> None: + """검사를 만들어 두고 안 부르면 없는 것과 같다 — 표가 실제로 부르는지 본다.""" + table = build_table(원단위표(성분("시멘트", "㎏", 100.0), surcharge_applied=True)) + assert table["double_count_warnings"] + + +def test_원단위_엔진_출력을_그대로_받음() -> None: + """두 엔진이 실제로 맞물리는지 — 모양이 어긋나면 여기서 깨진다.""" + unit = build_unit_table( + [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 35.0, + "end_m": 45.0, + "options": {"height_m": 1.5, "length_m": 10.0}, + } + ] + ) + table = build_table(unit) + assert table["row_count"] > 0 + assert not table["double_count_warnings"] + # 돌은 자재, 터파기는 토공 — 표에는 자재만 온다. + # ⚠ 종류를 안 고른 구조물의 돌 줄 이름이 「야면석」 → 「돌」로 바뀜(2026-09-09). + assert any(row["name"] == "돌" for row in table["rows"]) + assert not any(row["name"] == "터파기" for row in table["rows"]) + + +def test_이_표가_할증을_붙인_곳임을_못박음() -> None: + """B09 가 다시 붙이지 않도록 깃발을 남긴다.""" + assert build_table(원단위표(성분("모래", "㎥", 1.0)))["surcharge_applied"] is True + + +def test_품셈에_포함된_항목은_또_붙이지_않음() -> None: + """품셈 1-3-1 단서 — 「할증이 포함ㆍ표시된 경우 중복 적용 금지」.""" + table = build_table(원단위표(성분("모래", "㎥", 10.0, surcharge_included=True))) + row = 줄(table, "모래") + assert row["total_amount"] == pytest.approx(10.0) + assert row["note"] == NOTE_INCLUDED + + +# ── destination 가르기 ────────────────────────────────────────────── + + +def test_material_만_모음() -> None: + table = build_table( + 원단위표( + 성분("야면석", "㎥", 5.0), + 성분("터파기", "㎥", 3.0, destination="earthwork"), + 성분("모르터", "㎥", 0.1, destination="unit_price"), + ) + ) + assert [row["name"] for row in table["rows"]] == ["야면석"] + + +def test_거른_것을_버리지_않고_세어_보임() -> None: + table = build_table( + 원단위표( + 성분("터파기", "㎥", 3.0, destination="earthwork"), + 성분("되메우기", "㎥", 1.0, destination="earthwork"), + 성분("모르터", "㎥", 0.1, destination="unit_price"), + ) + ) + assert table["skipped_by_destination"] == {"earthwork": 2, "unit_price": 1} + + +def test_같은_자재는_구조물을_넘어_합쳐짐() -> None: + table = build_table( + { + "structures": [ + {"name": "A", "components": [성분("야면석", "㎥", 2.0)]}, + {"name": "B", "components": [성분("야면석", "㎥", 3.0)]}, + ], + "surcharge_applied": False, + } + ) + row = 줄(table, "야면석") + assert row["net_amount"] == pytest.approx(5.0) + assert row["sources"] == ["A", "B"] + + +def test_단위가_다르면_다른_줄() -> None: + table = build_table( + 원단위표(성분("떼", "㎡", 100.0), 성분("떼", "매", 50.0)), + ) + assert table["row_count"] == 2 + + +# ── 할증률은 데이터에서, 없으면 드러낸다 ──────────────────────────── + + +def test_실제_데이터판이_읽힘() -> None: + table = load_surcharge_table() + assert table.effective_date + assert "시멘트" in table.material_names + + +def test_할증률이_코드에_없고_표에서_옴() -> None: + """표를 갈아 끼우면 결과가 따라간다 — 값이 코드에 박혀 있으면 안 바뀐다.""" + fake = SurchargeTable( + effective_date="9999-01-01", rates={"모래": {"material": "모래", "rate": 50}} + ) + row = 줄(build_table(원단위표(성분("모래", "㎥", 10.0)), surcharge_table=fake), "모래") + assert row["surcharge_pct"] == 50 + assert row["total_amount"] == pytest.approx(15.0) + + +def test_표에_없는_자재는_0퍼센트로_넘기지_않음() -> None: + """0 % 로 조용히 넘기면 「할증 없음」과 「값을 못 찾음」이 구별되지 않는다.""" + table = build_table(원단위표(성분("낯선자재", "㎥", 10.0))) + row = 줄(table, "낯선자재") + assert row["surcharge_pct"] is None + assert row["note"] == NOTE_RATE_MISSING + assert "낯선자재" in table["missing_rate_materials"] + # 값은 잃지 않는다 — 순수량 그대로 둔다. + assert row["total_amount"] == pytest.approx(10.0) + + +def test_데이터_파일이_없으면_전부_미확보로_드러남() -> None: + empty = SurchargeTable() + table = build_table(원단위표(성분("모래", "㎥", 1.0)), surcharge_table=empty) + assert table["missing_rate_materials"] == ["모래"] + + +def test_출처판을_응답에_남김() -> None: + """어느 판으로 계산했는지 표가 스스로 말한다.""" + assert build_table(원단위표(성분("모래", "㎥", 1.0)))["surcharge_dataset"]["effective_date"] + + +# ── 할증 전·후 둘 다 ──────────────────────────────────────────────── + + +def test_전후_값이_둘_다_남음() -> None: + """하나만 넘기면 B09 가 어느 쪽인지 몰라 역산하다 사고가 난다(8-2).""" + fake = SurchargeTable(rates={"자갈": {"material": "자갈", "rate": 4}}) + row = 줄(build_table(원단위표(성분("자갈", "㎥", 100.0)), surcharge_table=fake), "자갈") + assert row["net_amount"] == pytest.approx(100.0) + assert row["total_amount"] == pytest.approx(104.0) + + +def test_합계는_반올림하지_않음() -> None: + """표기 자리와 계산 자리를 가른다(PLAN 8-16) — 반올림은 화면에서만.""" + fake = SurchargeTable(rates={"모래": {"material": "모래", "rate": 6}}) + row = 줄(build_table(원단위표(성분("모래", "㎥", 1.0)), surcharge_table=fake), "모래") + assert row["total_amount"] == pytest.approx(1.06) + + +# ── 관급/사급 ─────────────────────────────────────────────────────── + + +def test_안_정한_자재는_미분류로_드러남() -> None: + table = build_table(원단위표(성분("야면석", "㎥", 5.0))) + assert 줄(table, "야면석")["supply"] == SUPPLY_UNKNOWN + assert 줄(table, "야면석")["supply_label"] == "미분류" + assert table["missing_supply_materials"] == ["야면석"] + + +def test_설정이_정한_구분을_따름() -> None: + table = build_table( + 원단위표(성분("시멘트", "㎏", 100.0), 성분("야면석", "㎥", 5.0)), + supply_map={ + "시멘트": {"supply": SUPPLY_OWNER, "install_by": INSTALL_BY_CONTRACTOR}, + "야면석": SUPPLY_CONTRACTOR, + }, + ) + assert 줄(table, "시멘트")["supply"] == "owner_supplied" + assert 줄(table, "시멘트")["supply_label"] == "관급" + assert 줄(table, "시멘트")["install_by_label"] == "도급자설치" + assert 줄(table, "야면석")["supply_label"] == "사급" + assert table["missing_supply_materials"] == [] + + +def test_사급_줄에는_설치주체가_안_붙음() -> None: + """안전관리비 대상액 밖이라 비워 두는 것이 맞다 — 잘못 적힌 값은 무시한다.""" + table = build_table( + 원단위표(성분("야면석", "㎥", 5.0)), + supply_map={"야면석": {"supply": SUPPLY_CONTRACTOR, "install_by": INSTALL_BY_OWNER}}, + ) + assert 줄(table, "야면석")["install_by"] is None + assert table["missing_install_by_materials"] == [] + + +def test_관급인데_설치주체를_안_정하면_드러남() -> None: + """기본값으로 때우면 안전관리비가 조용히 틀린다 — 「도급자설치 관급금액」이 대상액이다.""" + table = build_table( + 원단위표(성분("시멘트", "㎏", 100.0)), + supply_map={"시멘트": SUPPLY_OWNER}, + ) + row = 줄(table, "시멘트") + assert row["install_by"] is None + assert NOTE_INSTALL_BY_MISSING in row["note"] + assert table["missing_install_by_materials"] == ["시멘트"] + + +def test_이름은_정확히_일치로만_찾음() -> None: + """부분일치면 `막자갈`(뒤채움)이 `자갈` 할증을 문다 — 원단위에서 이미 겪은 자리다.""" + fake = SurchargeTable(rates={"자갈": {"material": "자갈", "rate": 4}}) + table = build_table(원단위표(성분("막자갈", "㎥", 100.0)), surcharge_table=fake) + row = 줄(table, "막자갈") + assert row["surcharge_pct"] is None + assert row["total_amount"] == pytest.approx(100.0) + + +# ── 구조물 밖에서 오는 자재 ──────────────────────────────────────── + + +def test_사면_계열_자재도_받음() -> None: + """떼·초류종자는 구조물 전개가 아니라 사면적에서 온다(8-4).""" + table = build_table( + 원단위표(성분("야면석", "㎥", 5.0)), + extra_materials=[ + {"name": "떼", "unit": "㎡", "amount": 200.0, "source": "성토면 떼붙임"}, + ], + ) + row = 줄(table, "떼") + assert row["net_amount"] == pytest.approx(200.0) + assert row["surcharge_pct"] == 10 # 품셈 1-3-1 「떼ㆍ초화류 10 %」 + assert row["total_amount"] == pytest.approx(220.0) + + +def test_열_이름에_금액이_없음() -> None: + """8-2 경계 — 금액은 B09 몫이다.""" + columns = build_table(원단위표(성분("모래", "㎥", 1.0)))["columns"] + assert not any("금액" in name or "단가" in name for name in columns) diff --git a/resources/tester/test_b08_observed_unit.py b/resources/tester/test_b08_observed_unit.py new file mode 100644 index 00000000..14d29b21 --- /dev/null +++ b/resources/tester/test_b08_observed_unit.py @@ -0,0 +1,196 @@ +"""관측 원단위표 검사 — PLAN 8-6·8-8 · 2026-09-07 3자 승인. + +이 일감의 위험은 계산이 아니라 **관측값을 늘려 쓰는 것**이다. + · 관측값은 그 규격에서만 맞다 — 벽·기초는 높이에 비례하지 않는다. + · 규격이 표에 없으면 가까운 값을 갖다 쓰지 않고 「원단위 미확보」로 드러낸다. + · 두 근거(식에서 나온 값 / 관측값)가 한 표에 섞이므로 줄마다 근거를 단다. +""" + +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_ObservedUnit import ( # noqa: E402 + BASIS_OBSERVED, + NOTE_UNIT_MISSING, + ObservedUnitTable, + expand_observed, + load_observed_table, + scale_for, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + BASIS_DERIVED, + build_table, + expand, +) + + +def 옹벽(height: float = 2.0, length: float = 10.0, form: str = "반중력식") -> dict: + return { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 100.0, + "end_m": 100.0 + length, + "options": {"form": form, "height_m": height, "length_m": length}, + } + + +def 성분(result, name: str): + return next(c for c in result.components if c.name == name) + + +# ── 규격이 맞을 때만 쓴다 ─────────────────────────────────────────── + + +def test_규격이_맞으면_연장만큼_곱해짐() -> None: + """`H=2.0 옹벽 10m` 는 같은 단면이 10m 이어진 것 — 개수를 세는 것이지 규격을 늘리는 게 아니다.""" + result = expand(옹벽(length=10.0)) + assert 성분(result, "콘크리트").amount == pytest.approx(13.5) # 1.35 ㎥/m × 10 + assert 성분(result, "유로폼").amount == pytest.approx(32.0) + + +def test_높이가_다르면_비례로_늘리지_않고_미확보() -> None: + """⚠ 이 시험이 이 파일의 핵심 — H=1.6 은 표에 없다. 1.35 × 0.8 로 만들지 않는다.""" + result = expand(옹벽(height=1.6)) + assert result.components == [] + # ⚠ 사용자에게 뜨는 말로 잰다 — 키 이름이 새면 안 된다(㉑). + assert any("자료에 없습니다" in note for note in result.notes) + + +def test_형식이_다르면_미확보() -> None: + result = expand(옹벽(form="캔틸레버식")) + assert result.components == [] + # ⚠ 사용자에게 뜨는 말로 잰다 — 키 이름이 새면 안 된다(㉑). + assert any("자료에 없습니다" in note for note in result.notes) + + +def test_미확보_알림에_있는_규격을_같이_알려줄것() -> None: + """「없다」만 말하면 사용자가 무엇을 고쳐야 할지 모른다. + ⚠ 규격도 **사람 말**로 보인다 — `form`·`height_m` 이 아니라 「옹벽 형식」·「높이(m)」.""" + result = expand(옹벽(height=1.6)) + note = next(n for n in result.notes if "자료에 있는 규격" in n) + assert "옹벽 형식 반중력식" in note and "높이(m) 2.0" in note + assert "form" not in note and "height_m" not in note + + +def test_규격이_비어_있으면_고르지_못함을_알림() -> None: + result = expand({"type_id": "retaining_wall", "options": {"length_m": 5.0}}) + assert result.components == [] + assert result.notes + + +# ── 근거를 줄마다 단다 ────────────────────────────────────────────── + + +def test_관측값에는_observed_근거가_붙음() -> None: + result = expand(옹벽()) + for component in result.components: + assert component.basis_kind == BASIS_OBSERVED + assert "관측 원단위" in component.basis + assert component.source + + +def test_식에서_나온_값은_derived_로_남음() -> None: + """두 근거가 한 표에 섞이므로 갈라 보여야 한다.""" + stone = expand( + { + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 1.5, "length_m": 10.0}, + } + ) + assert all(c.basis_kind == BASIS_DERIVED for c in stone.components) + + +def test_표에도_근거가_실림() -> None: + """⚠ 관측 줄에 **파생 줄 하나가 섞인다** — 기초잡석은 버림 폭에서 나오는 값이라 + 관측이 아니라 `derived` 다(2026-09-09 확정 3차 ②). 근거가 갈려 실리는 것이 맞다.""" + table = build_table([옹벽()]) + components = [c for s in table["structures"] for c in s["components"]] + kinds = {c["basis_kind"] for c in components if c["name"] != "기초잡석"} + assert kinds == {BASIS_OBSERVED} + 잡석 = next(c for c in components if c["name"] == "기초잡석") + assert 잡석["basis_kind"] == BASIS_DERIVED + + +# ── 이중계상 규칙은 그대로 ────────────────────────────────────────── + + +def test_관측값에도_배합이_섞이지_않음() -> None: + """㉢ — 콘크리트 ㎥ 까지만. 시멘트·모래로 쪼개는 것은 B09 몫이다.""" + assert build_table([옹벽()])["mix_components_found"] == [] + + +def test_터파기는_토공으로_되메우기는_토공으로() -> None: + """관측 원단위의 터파기도 내역 줄이 아니라 토공 합산이다.""" + table = load_observed_table() + entry = table.find("pipe_inlet_basin", {"inlet_basin_form": "돌집수정 ㄷ형"}) + destinations = {c["name"]: c["destination"] for c in entry["components"]} + assert destinations["터파기"] == "earthwork" + assert destinations["잔토처리"] == "earthwork" + assert destinations["콘크리트"] == "unit_price" + + +def test_할증은_여전히_자재총괄_몫() -> None: + assert build_table([옹벽()])["surcharge_applied"] is False + + +# ── 표 자체 ───────────────────────────────────────────────────────── + + +def test_실제_데이터판이_읽힘() -> None: + table = load_observed_table() + assert table.effective_date + assert table.find("retaining_wall", {"form": "반중력식", "height_m": 2.0}) + + +def test_숫자와_글자_규격을_같이_본다() -> None: + """입력 폼이 `"800"` 을 문자열로 준다 — 그렇다고 `2.0` 과 `1.6` 을 같다고 보면 안 된다.""" + table = load_observed_table() + assert table.find( + "pipe_inlet_basin", + { + "inlet_basin_form": "□형(기본형)", + "inlet_basin_material": "콘크리트", + "pipe_diameter_mm": 800, + }, + ) + assert table.find("retaining_wall", {"form": "반중력식", "height_m": 1.9999}) is None + + +def test_BOX암거는_미확보로_남음() -> None: + """⚠ 두께가 저장돼 있지 않아 식도 못 세우고 관측값도 없다 — 지어내면 콘크리트·거푸집· + 철근으로 번져 나간다. 사용자 확정 대기.""" + table = load_observed_table() + assert table.find("box_culvert", {"body_width_m": 3.0, "body_height_m": 1.2}) is None + reasons = [item["type_id"] for item in table.not_found["items"]] + assert "box_culvert" in reasons + + +def test_파일이_없으면_전부_미확보() -> None: + empty = ObservedUnitTable() + components, notes = expand_observed("retaining_wall", {"form": "반중력식"}, 옹벽(), empty) + assert components == [] + # 자료가 통째로 없으면 「표준 물량 자료가 없다」로 말한다. + assert "표준 물량 자료가 아직 없습니다" in notes[0] + + +def test_곱할_연장이_0이면_내지_않음() -> None: + result = expand(옹벽(length=0.0)) + assert result.components == [] + + +def test_개소당_원단위는_안_곱해짐() -> None: + """집수정은 1개소가 1개소다 — 연장을 곱하면 값이 부푼다.""" + table = load_observed_table() + entry = table.find("pipe_inlet_basin", {"inlet_basin_form": "돌집수정 ㄴ형"}) + scale, note = scale_for(entry, {"options": {"length_m": 10.0}}) + assert scale == 1.0 + assert "개소" in note diff --git a/resources/tester/test_b08_option_keys.py b/resources/tester/test_b08_option_keys.py new file mode 100644 index 00000000..ba9985dc --- /dev/null +++ b/resources/tester/test_b08_option_keys.py @@ -0,0 +1,116 @@ +"""저장 제원 키 ↔ 엔진이 읽는 키 대조 (2026-09-07 ⑳ 앞). + +⚠ **오늘 잡은 사고 중 사용자 입력이 직접 무시되던 첫 사례**가 여기였다 — + 레지스트리는 `back_len_cm` 인데 엔진은 `stone_back_length_cm` 을 읽고 있어 + **뒷길이를 75 로 골라도 늘 45 계수**로 돌았다. 값이 나오므로 아무 시험도 안 잡았다. + +⇒ 「이름으로 알아보는 코드는 정본과 대조하라」(사방공 `type_id` 건)의 **데이터 키 판**이다. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +#: 레지스트리에 칸이 **아직 없는** 키. 「칸이 생기면 받는다」는 뜻으로 코드에 남겨 둔 것이고, +#: 키 이름이 어긋난 것과는 다르다. 새로 늘리려면 **왜 없는지**를 여기 적을 것. +#: 레지스트리에 아직 칸이 없는 키 — **왜 없는지**를 함께 적는다. +#: ⚠ 2026-09-09 `face_slope_ratio` 는 칸이 생겨 뺐음(`ebdf2988`). 비워 두면 품셈 +#: 표준경사표로 자동 판정되고, 값이 있으면 그 값이 이긴다(확정 ⑨). +#: ⓘ 2026-09-09 오후 비었다 — `ditch_spec` 은 랩탑 보조가 칸을 만들어(`92fd9092`) 여기서 뺐다. +#: 목록이 비어 있는 것이 정상이다. **칸 없이 코드에만 있는 키가 생기면** 여기 까닭과 함께 +#: 적을 것 — 적지 않으면 이 시험이 「이름을 잘못 적었다」로 잡는다. +KNOWN_ABSENT: dict[str, str] = {} + + +def _registry_keys() -> set[str]: + payload = json.loads( + (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + ) + types = payload if isinstance(payload, list) else (payload.get("types") or []) + return {option["key"] for item in types for option in item.get("options") or []} + + +def _engine_keys() -> dict[str, set[str]]: + found: dict[str, set[str]] = {} + for path in (ROOT / "B08_Quantity").glob("*.py"): + text = path.read_text(encoding="utf-8") + for match in re.finditer(r'options\.get\(\s*"([a-z0-9_]+)"', text): + found.setdefault(match.group(1), set()).add(path.name) + for path in (ROOT / "resources").rglob("*.json"): + try: + text = path.read_text(encoding="utf-8") + except OSError: + continue + for match in re.finditer(r'"option_key"\s*:\s*"([a-z0-9_]+)"', text): + found.setdefault(match.group(1), set()).add(path.name) + return found + + +def test_엔진이_읽는_키가_레지스트리에_있을것() -> None: + """⚠ 이 시험이 `back_len_cm` 사고를 다시 못 나게 한다.""" + registry = _registry_keys() + missing = { + key: sorted(where) + for key, where in _engine_keys().items() + if key not in registry and key not in KNOWN_ABSENT + } + assert not missing, f"레지스트리에 없는 옵션 키를 읽고 있다: {missing}" + + +def test_없는_키는_사유가_적혀_있을것() -> None: + """「없다」를 그냥 넘기지 않는다 — 왜 없는지가 적혀 있어야 한다.""" + for key, why in KNOWN_ABSENT.items(): + assert why, key + # 사유 목록에 적힌 키가 정말 레지스트리에 없는지도 본다(고쳐졌는데 목록만 남는 것 방지). + registry = _registry_keys() + stale = [key for key in KNOWN_ABSENT if key in registry] + assert not stale, f"레지스트리에 칸이 생겼으니 목록에서 뺄 것: {stale}" + + +def test_돌쌓기_뒷길이_키가_실제로_맞을것() -> None: + """사고가 났던 그 자리를 이름으로 직접 못 박는다.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import BACK_LENGTH_KEYS + + assert "back_len_cm" in BACK_LENGTH_KEYS + assert "back_len_cm" in _registry_keys() + + +def test_되돌릴_수_있어야_하는_숫자_칸은_계약에_등록될것() -> None: + """⚠ `None` 을 통째로 버리면 「안 정함」으로 되돌릴 길이 없다 (2026-09-08 ㉘). + + 시공법·타설 방식은 빈 문자열로 되돌리지만 **숫자 칸은 되돌릴 값이 `None` 뿐**이다. + 새 숫자 칸을 넣고 여기 등록하지 않으면 **한 번 넣은 값이 영영 남는다.** + 같은 병을 두 번 만났으므로 계약처럼 다룬다. + """ + from B08_Quantity.B08_Quantity_Router_Earthwork import ( + NULLABLE_SETTING_KEYS, + QuantitySettingsBody, + ) + + # ⚠ 좁게 본다 — `dict[str, float]`(비율표)까지 걸면 정상 칸을 잡는다(넓은 규칙 사고). + # 여기서 겨냥하는 것은 **홑 숫자 칸**뿐이다. + 홑숫자 = {float | None, int | None} + 숫자칸 = { + name + for name, field in QuantitySettingsBody.model_fields.items() + if field.annotation in 홑숫자 + } + assert 숫자칸, "홑 숫자 칸을 하나도 못 골랐다 — 판정이 틀렸다" + 빠진것 = 숫자칸 - set(NULLABLE_SETTING_KEYS) + assert not 빠진것, f"숫자 칸이 NULLABLE_SETTING_KEYS 에 없다 — 되돌릴 길이 없다: {sorted(빠진것)}" + + +def test_되돌리기_칸은_갈아끼우기_대상이기도_할것() -> None: + """등록만 하고 갈아끼우기(`replace_keys`)에서 빠지면 병합돼 옛 값이 남는다.""" + import inspect + + from B08_Quantity import B08_Quantity_Router_Earthwork as router + + 본문 = inspect.getsource(router.save_quantity_settings) + assert "NULLABLE_SETTING_KEYS" in 본문 diff --git a/resources/tester/test_b08_pipe.py b/resources/tester/test_b08_pipe.py new file mode 100644 index 00000000..9d19d652 --- /dev/null +++ b/resources/tester/test_b08_pipe.py @@ -0,0 +1,172 @@ +"""배수관 물량 — 정본 셋을 잇는 자리의 짝 시험 (2026-09-08). + +⚠ 여기서 겨누는 것 넷 + ① `facility` 가 있는 점(세월교 등)을 **관으로 세지 않는가** + ② 연장이 없으면 **0 으로 때우지 않고 사유와 함께 막히는가** + ③ 관종으로 공종이 갈리는가 · 기본값으로 선 것을 **알리는가** + ④ ⚠ 좁게 — 측점 맞추기가 **옆 측점 길이를 물어 오지 않는가** +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows # noqa: E402 + +MAPPING = json.loads( + ( + ROOT / "resources" / "data_work_item_mapping" / "work_item_mapping_2026-01-01.json" + ).read_text(encoding="utf-8") +)["pipe"] + + +def 관(chainage: float, **options: object) -> dict: + return {"chainage_m": chainage, "facility": "pipe", "options": options} + + +def 길이(chainage: float, length: float) -> dict: + return {"chainage_m": chainage, "design": {"pipe_length_m": length}} + + +def test_관이_아닌_시설은_안_센다() -> None: + """⚠ `pipe_points.json` 은 계곡 통과 시설 **전부**의 정본이다 — 세월교가 섞여 있다.""" + points = [ + 관(85.05, pipe_diameter_mm=1000), + {"chainage_m": 173.09, "facility": "ford_bridge", "options": {}}, + {"chainage_m": 352.14, "facility": "ford_bridge", "options": {}}, + 관(258.12, pipe_diameter_mm=800), + ] + out = build_rows(points, [], MAPPING) + assert out["pipe_count"] == 2, "세월교를 관으로 셌다" + + +def test_관경이_없어도_관은_관이다() -> None: + """⚠ `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**.""" + out = build_rows([관(100.0)], [], MAPPING) + assert out["pipe_count"] == 1 + assert out["rows"][0]["variant_value"] is None + + +def test_연장이_없으면_0_으로_때우지_않는다() -> None: + out = build_rows([관(85.05, pipe_diameter_mm=1000)], [], MAPPING) + row = out["rows"][0] + assert row["in_bill"] is False + assert row["blocked_kind"] == "input_missing" + assert "횡단설계에서 [저장]" in row["blocked_reason"] + + +def test_연장이_오면_값이_선다() -> None: + out = build_rows([관(85.05, pipe_diameter_mm=1000)], [길이(85.05, 9.0)], MAPPING) + row = out["rows"][0] + assert (row["in_bill"], row["quantity"], row["unit"]) == (True, 9.0, "m") + assert out["length_total_m"] == 9.0 + + +def test_관종으로_공종이_갈린다() -> None: + 보기 = {"파형강관": "FP-12-11-03", "흄관": "FP-12-11-02", "VR관": "FP-12-11-01"} + for kind, code in 보기.items(): + out = build_rows([관(85.0, pipe_kind=kind, pipe_diameter_mm=800)], [길이(85.0, 9.0)], MAPPING) + assert out["rows"][0]["work_item_code"] == code, kind + + +def test_관종을_안_정하면_기본값으로_서되_알린다() -> None: + """기본 「파형강관」은 2026-08-17 사용자 확정값이라 근거가 있다 — 다만 **조용히 쓰지 않는다**.""" + out = build_rows([관(85.0, pipe_diameter_mm=800)], [길이(85.0, 9.0)], MAPPING) + row = out["rows"][0] + assert row["work_item_code"] == "FP-12-11-03" + assert row["kind_from_default"] is True + assert any("기본값" in note for note in out["notes"]) + + +def test_모르는_관종은_공종을_못_고른다() -> None: + out = build_rows([관(85.0, pipe_kind="철근콘크리트관")], [길이(85.0, 9.0)], MAPPING) + row = out["rows"][0] + assert row["work_item_code"] is None + assert row["in_bill"] is False + assert "아는 관종이 아니라" in row["blocked_reason"] + + +def test_측점이_소수점에서_어긋나도_찾는다() -> None: + out = build_rows([관(85.05, pipe_diameter_mm=800)], [길이(85.0, 9.0)], MAPPING) + assert out["rows"][0]["quantity"] == 9.0 + + +def test_옆_측점_길이를_물어_오지_않는다() -> None: + """⚠ 좁게 — 넓히면 다른 측점 값이 조용히 실린다.""" + out = build_rows([관(85.0, pipe_diameter_mm=800)], [길이(120.0, 9.0)], MAPPING) + row = out["rows"][0] + assert row["quantity"] == 0.0 and row["in_bill"] is False + + +def test_실제_자료로_세월교가_걸러진다() -> None: + """정본 대조 — `5601e828` 은 11점 중 관 9 · 세월교 2 임(2026-09-08 랩탑 창 확인).""" + path = ( + ROOT + / "storage" + / "1" + / "3" + / "5601e828-feea-487a-9b25-415e5199f2f5" + / "B04_PreProcess" + / "drainage" + / "edits" + / "pipe_points.json" + ) + if not path.is_file(): + return + points = json.loads(path.read_text(encoding="utf-8"))["points"] + assert build_rows(points, [], MAPPING)["pipe_count"] == 9 + + +# ── 「[저장]하면 풀림」과 「눌러도 안 풀림」을 갈라 말하기 (2026-09-08 실측) ──── +# 실측: `5601e828` 관 9개 중 3개(439.55·620.43·720.37)가 **횡단 행 자체가 없는** 자리였다. +# 거기에 「[저장]을 누르면 섭니다」를 띄우면 눌러 보고 안 되어 헤맨다. + + +def test_횡단이_있는데_길이만_없으면_저장하라고_말한다() -> None: + out = build_rows( + [관(85.0, pipe_diameter_mm=800)], + [], + MAPPING, + section_chainages=[85.0], + ) + assert "[저장]을 한 번 누르면" in out["rows"][0]["blocked_reason"] + + +def test_횡단_자체가_없으면_다르게_말한다() -> None: + out = build_rows( + [관(620.43, pipe_diameter_mm=1500)], + [], + MAPPING, + section_chainages=[85.0, 258.12], + ) + 사유 = out["rows"][0]["blocked_reason"] + assert "횡단 자체가 없습니다" in 사유 + assert "[저장]으로는 안 풀립니다" in 사유 + + +def test_측점_목록을_안_주면_종전대로_말한다() -> None: + """⚠ 좁게 — 목록이 없다고 「횡단 없음」으로 단정하면 거짓말이 된다.""" + out = build_rows([관(620.43, pipe_diameter_mm=1500)], [], MAPPING) + assert "[저장]을 한 번 누르면" in out["rows"][0]["blocked_reason"] + + +def test_0_5m_안의_측점은_그_관의_횡단이다() -> None: + """⚠ **뒤집힌 시험이다** — 종전에는 「옆 측점이 있다고 횡단이 있는 것이 아니다」였다. + + 2026-09-09 랩탑 메인 실측이 그 전제를 깼다 — 측점은 **정수 미터 격자로 스냅**되므로 + 관 439.55 의 횡단은 **측점 440.0** 이고, 그 측점은 구조물 이름표까지 달고 있다. + 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가 뜬다. 스냅 최대 + 어긋남이 0.5m 라 길이 찾기와 허용오차가 같아졌다(`e1fed5f6` — 배수관 넷 복구). + """ + out = build_rows( + [관(439.55, pipe_diameter_mm=800)], + [], + MAPPING, + section_chainages=[440.0], + ) + assert "[저장]을 한 번 누르면" in out["rows"][0]["blocked_reason"] diff --git a/resources/tester/test_b08_preparation.py b/resources/tester/test_b08_preparation.py new file mode 100644 index 00000000..4581ebd7 --- /dev/null +++ b/resources/tester/test_b08_preparation.py @@ -0,0 +1,232 @@ +"""준비공·사방공 검사 — 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"] == "골막이" diff --git a/resources/tester/test_b08_rubble_base.py b/resources/tester/test_b08_rubble_base.py new file mode 100644 index 00000000..b5e9fcfa --- /dev/null +++ b/resources/tester/test_b08_rubble_base.py @@ -0,0 +1,115 @@ +"""기초잡석 — 버림 폭에서 나온다 (2026-09-09 사용자 확정 3차 ②). + +⚠ 겨누는 것 여섯 + ① 두께는 **0.2 m**(사용자 확정) — 품셈 12-25 는 ㎥당 품만 주고 두께를 안 정함 + ② 폭을 다시 세지 않는다 — **버림 폭이 곧 잡석다짐 폭**(KCS 34 50 05)이라 두께 비만 곱함 + ③ ⚠ 그래서 **관측 원단위로 오는 옹벽에도 값이 선다**(버림 0.15㎥/m ⇒ 잡석 0.30㎥/m) + ④ 두께는 화면에서 바꿀 수 있다 + ⑤ ⚠ 자재총괄에 안 섞인다 — 운반·부설·다짐 품이 붙는 **공종**이다 + ⑥ ⚠ 묶음으로 서는 구조물은 줄을 또 세우지 않는다(옹벽 조각이 이미 셈) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402 + build_table as build_material, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + RUBBLE_BASE_THICKNESS_M, + build_table, +) + + +def 돌쌓기(thickness: float | None = None) -> dict: + return build_table( + [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": { + "height_m": 2.5, + "length_m": 10.0, + "back_len_cm": 45, + "foundation": "기초유", + }, + } + ], + None, + None, + None, + thickness, + ) + + +def 옹벽() -> dict: + return build_table( + [ + { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 100.0, + "end_m": 110.0, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, + } + ], + {"retaining_wall": "옹벽"}, + ) + + +def 성분(table: dict, name: str) -> dict | None: + for structure in table["structures"]: + for component in structure["components"]: + if component["name"] == name: + return component + return None + + +def test_두께는_사용자_확정값() -> None: + assert RUBBLE_BASE_THICKNESS_M == 0.2 + + +def test_버림_두께_비로_나온다() -> None: + """버림 1.2㎥ × (0.2 ÷ 0.1) = 2.4㎥ — 폭을 다시 세지 않는다.""" + table = 돌쌓기() + assert abs(성분(table, "버림콘크리트")["amount"] - 1.2) < 1e-9 + assert abs(성분(table, "기초잡석")["amount"] - 2.4) < 1e-9 + + +def test_관측_원단위_구조물에도_선다() -> None: + """⚠ 옹벽은 관측 원단위로 오는데 그 표에 기초잡석 줄이 없다 — 버림에서 나온다.""" + assert abs(성분(옹벽(), "기초잡석")["amount"] - 3.0) < 1e-9 # 0.30㎥/m × 10m + + +def test_두께를_바꾸면_따라간다() -> None: + assert abs(성분(돌쌓기(0.3), "기초잡석")["amount"] - 3.6) < 1e-9 + + +def test_자재총괄에_안_섞인다() -> None: + """⚠ 운반·부설·다짐 품이 붙는 공종이다 — 자재로 서면 같은 잡석을 두 번 센다.""" + assert 성분(돌쌓기(), "기초잡석")["destination"] == "unit_price" + assert all(row["name"] != "기초잡석" for row in build_material(돌쌓기())["rows"]) + + +def test_인계에_공종_줄로_선다() -> None: + rows = build_handoff(unit_quantity_table=돌쌓기())["work_items"] + row = next(r for r in rows if r["name"] == "기초잡석") + assert row["work_item_code"] == "FP-12-25" + assert abs(row["quantity"] - 2.4) < 1e-9 and row["in_bill"] is True + + +def test_묶음_구조물은_줄을_또_세우지_않는다() -> None: + """⚠ 옹벽 묶음에 이미 FP-12-25 조각이 있다 — 여기서 또 세우면 두 번 센다.""" + rows = build_handoff(unit_quantity_table=옹벽())["work_items"] + assert all(row["name"] != "기초잡석" for row in rows) + parts = rows[0]["composite_parts"] + gravel = next(part for part in parts if part["code"] == "FP-12-25") + assert gravel["quantity"] == 3.0 diff --git a/resources/tester/test_b08_slope_area.py b/resources/tester/test_b08_slope_area.py new file mode 100644 index 00000000..97b00e74 --- /dev/null +++ b/resources/tester/test_b08_slope_area.py @@ -0,0 +1,260 @@ +"""사면길이 유도·사면 4계열 면적 검사 — PLAN 8-4b·8-11. + +사면길이는 저장분에서 **유도**하는 값이라 판정 규칙이 곧 정확성이다. 그래서 +실측 설계선 모양을 그대로 넣어 사면과 원지반이 갈리는지를 못 박는다. +""" + +from __future__ import annotations + +import math +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_SlopeArea import ( # noqa: E402 + SlopeRatios, + build_rows, + build_table, + totals, + unclosed_stations, +) +from B08_Quantity.B08_Quantity_Engine_SlopeLength import ( # noqa: E402 + StationSlope, + station_slope, +) + + +def line(points: list[tuple[float, float]]) -> list[dict[str, float]]: + return [{"offset_m": o, "elevation_m": z} for o, z in points] + + +def 절토_설계선() -> dict: + """측점 20.0 **실측 모양 그대로** — 노면·측구·2단 절토 사면·원지반 (우측). + + 실측 구간 기울기: 노면 n=33.3 · 측구 벽 n=1.0 · 절토(암) n=0.4 · 절토(토사) n=1.0 · + 원지반 n=1.63. 측구 바닥은 노면보다 **낮다**(깊이 0.3). + ⚠ 측구 벽이 토사 절토비(1.0)와 같은 경사라, 이 모양이 곧 「측구를 사면으로 세지 않는가」 + 를 재는 시험이다. + """ + return { + # 실측 표고를 그대로 세운 것 — 노면(-2.00) 870.83 을 기준으로 바깥으로 쌓았다. + "design_line": line( + [ + (-5.00, 873.73), # 원지반 (n=1.63) + (-4.50, 873.32), # 절토 토사 n=1.0 + (-4.00, 872.82), # 절토 토사 n≈1.03 + (-3.50, 872.33), # 절토 암 n=0.4 + (-3.00, 871.08), # 절토 암 n=0.4 + (-2.90, 870.83), + (-2.60, 870.53), # 측구 바깥 벽 n=1.0 — 사면이 아니다 + (-2.30, 870.53), # 측구 바닥 (노면보다 0.3 낮다) + (-2.00, 870.83), # 노체 끝 + (0.00, 870.89), # 노면 + ] + ), + "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, + "ditch_enabled": True, + "ditch_side": "right", + "ditch": {"type": "standard", "top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, + "cut_slope_ratio": 0.4, + "soil_cut_slope_ratio": 1.0, + "fill_slope_ratio": 1.2, + "two_stage_slope": True, + } + + +def 성토_설계선() -> dict: + """성토 사면 n=1.2 가 이어지다 원지반(n=2.5)에서 끊긴다.""" + return { + "design_line": line( + [ + (2.0, 870.00), # 노체 끝 + (4.4, 868.00), # 성토 사면 n=1.2 + (6.8, 866.00), + (9.8, 864.80), # 원지반 n=2.5 + ] + ), + "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, + "cut_slope_ratio": 0.4, + "soil_cut_slope_ratio": 1.0, + "fill_slope_ratio": 1.2, + } + + +# ── 사면길이 유도 ──────────────────────────────────────────────────── + + +def test_절토_사면만_잡고_원지반은_끊음() -> None: + slope = station_slope(20.0, 절토_설계선()) + # 암 두 조각(0.10/0.25 · 0.50/1.25) + 토사 두 조각(0.50/0.49 · 0.50/0.50). + # 원지반(-4.50~-5.00, n=1.22)은 경사비가 안 맞아 안 든다. + expected = ( + math.hypot(0.10, 0.25) + + math.hypot(0.50, 1.25) + + math.hypot(0.50, 0.49) + + math.hypot(0.50, 0.50) + ) + assert slope.cut_length_m == pytest.approx(expected, rel=0.02) + + +def test_절토_2단이_암토사로_갈림() -> None: + """8-11 의 암 5분류는 설계자 % 입력 몫 — 여기서는 2단 경계까지만 본다.""" + slope = station_slope(20.0, 절토_설계선()) + materials = {segment.material for segment in slope.segments if segment.role == "cut"} + assert materials == {"rock", "soil"} + + +def test_측구는_사면이_아님() -> None: + """측구 벽도 n=1.0 이라 토사 절토비와 같다 — 노체 안쪽이므로 세면 안 된다.""" + slope = station_slope(20.0, 절토_설계선()) + for segment in slope.segments: + assert segment.from_offset_m <= -2.9 + 1e-6, f"측구 구간이 섞였다: {segment}" + + +def test_성토_사면도_유도됨() -> None: + """원지반선 없이도 경사비 불일치로 끊긴다 — 이 길이 3번의 갈림길이었다.""" + slope = station_slope(40.0, 성토_설계선()) + assert slope.fill_length_m == pytest.approx(math.hypot(4.8, 4.0), rel=0.02) + assert slope.cut_length_m == 0.0 + + +def 성토_실측_설계선() -> dict: + """route 150 측점 20.0 **좌측 실측 좌표** — 성토 사면이 원지반을 만나 끊기는 자리. + + 2.00~6.95 가 n=1.2 로 일정하고 6.95 에서 n=2.0 으로 꺾인다. 그 꺾임이 곧 원지반이다. + ⚠ 이 시험이 있는 까닭 — 「성토 사면적이 실무의 2.8배」라는 의심이 들어 손으로 따라간 + 자리다. 결과는 **엔진이 맞았고** 삼각형 근사(평지 가정)로 견준 쪽이 틀렸다. + 지반이 기울어 있으면 같은 면적이라도 사면이 훨씬 길어진다. + """ + return { + "design_line": line( + [ + (2.00, 870.323), + (2.50, 869.906), + (3.00, 869.490), + (3.50, 869.073), + (4.00, 868.656), + (4.50, 868.240), + (5.00, 867.823), + (5.50, 867.406), + (6.00, 866.990), + (6.50, 866.573), + (6.95, 866.202), # 여기까지가 사면 — 낙차 4.121m + (7.00, 866.177), # 원지반 n=2.0 + (7.50, 865.921), + (8.00, 865.716), + ] + ), + "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, + "ditch_enabled": True, + "ditch_side": "right", # 좌측에는 측구가 없다 + "ditch": {"top_width_m": 0.9}, + "fill_slope_ratio": 1.2, + "cut_slope_ratio": 0.4, + "soil_cut_slope_ratio": 1.0, + } + + +def test_성토_사면이_원지반에서_정확히_끊김() -> None: + """실측 좌표로 잰다 — 사면길이 6.44m(수평 4.95 · 낙차 4.121).""" + slope = station_slope(20.0, 성토_실측_설계선()) + assert slope.fill_length_m == pytest.approx(math.hypot(4.95, 4.121), rel=0.005) + assert slope.fill_length_m == pytest.approx(6.44, abs=0.01) + # 원지반(7.00 바깥)은 한 조각도 안 든다. + assert max(s.to_offset_m for s in slope.segments) == pytest.approx(6.95) + + +def test_잘린_측점은_표시가_따라옴() -> None: + design = dict(성토_설계선(), slope_unclosed=True) + assert station_slope(40.0, design).unclosed is True + + +def test_설계선이_없으면_0() -> None: + assert station_slope(0.0, {}).cut_length_m == 0.0 + + +# ── 사면 4계열 면적 ────────────────────────────────────────────────── + + +def 사면들() -> list[StationSlope]: + return [ + StationSlope(chainage_m=0.0, cut_length_m=0.0, fill_length_m=0.0), + StationSlope(chainage_m=20.0, cut_length_m=4.0, fill_length_m=6.0), + StationSlope(chainage_m=40.0, cut_length_m=6.0, fill_length_m=2.0), + ] + + +def test_면적은_평균단면적법() -> None: + """토적표와 같은 식이다 — 체적 대신 면적이 나올 뿐 계산을 두 벌로 짜지 않는다.""" + rows = build_rows(사면들()) + assert rows[1].areas["face_dressing_cut"] == pytest.approx((0.0 + 4.0) / 2 * 20) + assert rows[2].areas["face_dressing_cut"] == pytest.approx((4.0 + 6.0) / 2 * 20) + assert rows[2].areas["face_dressing_fill"] == pytest.approx((6.0 + 2.0) / 2 * 20) + + +def test_첫_측점은_면적이_없음() -> None: + assert all(value == 0.0 for value in build_rows(사면들())[0].areas.values()) + + +def test_층따기는_성토면만() -> None: + rows = build_rows(사면들()) + assert "bench_cut_fill" in rows[1].areas + assert "bench_cut_cut" not in rows[1].areas + + +def test_법면보호공은_면고르기와_같은_값() -> None: + """기본은 참조다 — 실무 시트가 그러했고 오솔길 산출에는 보호공이 비어 있었다.""" + row = build_rows(사면들())[2] + assert row.areas["slope_protection_cut"] == pytest.approx(row.areas["face_dressing_cut"]) + assert row.areas["slope_protection_fill"] == pytest.approx(row.areas["face_dressing_fill"]) + + +def test_반영률_기본은_100퍼센트() -> None: + """실무 관측 80/50/80 은 참고일 뿐 기본값이 아니다 — 법대로 방침(8-10 ★).""" + ratios = SlopeRatios() + assert (ratios.bench_cut, ratios.face_dressing, ratios.slope_protection, ratios.tree_removal) == ( + 1.0, + 1.0, + 1.0, + 1.0, + ) + + +def test_반영률을_주면_곱해짐() -> None: + plain = build_rows(사면들())[2].areas["face_dressing_cut"] + scaled = build_rows(사면들(), SlopeRatios(face_dressing=0.8))[2].areas["face_dressing_cut"] + assert scaled == pytest.approx(plain * 0.8) + + +def test_잘린_측점은_목록으로_드러남() -> None: + """조용히 적게 내면 안 된다 — 화면이 이 목록을 그대로 보인다.""" + slopes = 사면들() + slopes[1].unclosed = True + rows = build_rows(slopes) + assert unclosed_stations(rows) == [20.0] + + +def test_합계() -> None: + rows = build_rows(사면들()) + total = totals(rows) + assert total["face_dressing_cut"] == pytest.approx( + sum(row.areas["face_dressing_cut"] for row in rows) + ) + + +def test_표_모양() -> None: + table = build_table(사면들()) + assert table["method"] == "average_end_area" + assert table["protection_source"] == "face_dressing" + assert table["station_count"] == 3 + assert set(table["ratios"]) == { + "bench_cut", + "face_dressing", + "slope_protection", + "tree_removal", + } + assert len(table["rows"]) == 3 diff --git a/resources/tester/test_b08_spoil_fill_not_in_earthwork.py b/resources/tester/test_b08_spoil_fill_not_in_earthwork.py new file mode 100644 index 00000000..7771f458 --- /dev/null +++ b/resources/tester/test_b08_spoil_fill_not_in_earthwork.py @@ -0,0 +1,48 @@ +"""사토장 성토가 노체 성토로 새지 않는가 (2026-09-09). + +⚠⚠ **둘은 다른 이야기다.** 사토장은 **없애는 곳이 아니라 목적지**다 — + 그 흙은 여전히 실어 내야 하고 여전히 사토다. 바뀌는 것은 **양이 아니라 처리처와 거리**. + 그리고 그 자리에 쌓인 흙은 **도로를 떠받치는 성토가 아니다** — 노체 성토로 세면 + 「흙이 모자라 사 와야 하는 양」이 실제보다 적어 보이는 착시가 생긴다. + +⇒ B06 이 `spoil_fill_area_m2` 로 **갈라서** 내고 본 면적(`fill_area_m2`)에서 뺐다. + 이 시험은 **우리 토적표가 그 갈래를 지키는지**를 본다. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_EarthworkTable import ( # noqa: E402 + StationArea, + build_table, +) + + +def 측점(chainage: float, fill: float, spoil: float) -> StationArea: + return StationArea.from_design( + chainage, + { + "fill_area_m2": fill, + "spoil_fill_area_m2": spoil, + "cut_soil_area_m2": 0.0, + "cut_rock_area_m2": 0.0, + }, + ) + + +def test_사토장_성토가_노체_성토에_안_섞인다() -> None: + """⚠ 섞이면 성토 물량이 갑자기 뛴다 — 사토장을 놓은 것만으로 흙 수요가 늘 리 없다.""" + 없을때 = build_table([측점(0.0, 10.0, 0.0), 측점(20.0, 10.0, 0.0)]) + 있을때 = build_table([측점(0.0, 10.0, 5.0), 측점(20.0, 10.0, 5.0)]) + assert 있을때["totals"]["fill_volume_m3"] == 없을때["totals"]["fill_volume_m3"] + + +def test_본_면적만_본다() -> None: + """토적표가 읽는 칸은 `fill_area_m2` 하나다 — 사토장 칸은 B06·인계가 따로 쓴다.""" + table = build_table([측점(0.0, 10.0, 5.0), 측점(20.0, 12.0, 5.0)]) + assert table["totals"]["fill_volume_m3"] == (10.0 + 12.0) / 2 * 20.0 diff --git a/resources/tester/test_b08_spoil_haul.py b/resources/tester/test_b08_spoil_haul.py new file mode 100644 index 00000000..86bfc359 --- /dev/null +++ b/resources/tester/test_b08_spoil_haul.py @@ -0,0 +1,173 @@ +"""사토를 실어 내는 줄 (2026-09-08). + +⚠⚠ **유토곡선이 사토를 내는데 아무도 실어 내지 않고 있었다.** 운반 줄은 띠(`bands`)와 + 이동(`transfers`)에서만 만들어지고 사토는 잔량(`residuals`)으로 남아 어느 쪽에도 없었다 — + 그래서 구조물 잔토 126.63㎥ 를 사토에 얹어도 **덤프가 하나도 안 늘었고**, 채집석 공제도 + 사토를 줄이는 값이라 **끝까지 금액에 안 나타났다**. + +⚠ 겨누는 것 다섯 + ① 사토가 있으면 줄이 선다 + ② 거리를 안 정했으면 **막고 사유** — 임의 거리는 그대로 금액이 된다 + ③ 거리를 정하면 덤프 코드가 붙고 금액이 선다 + ④ ⚠ 자연방토는 실어 내지 않으므로 **뺀다** + ⑤ 사토가 없으면 줄도 없다 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402 +from B08_Quantity.B08_Quantity_Router_Earthwork import _spoil_of # noqa: E402 + + +def 줄(**spoil: object) -> dict | None: + table = {"rows": [], "spoil": spoil} + rows = build_handoff(haul_table=table)["work_items"] + found = [row for row in rows if row["name"] == "사토 운반"] + return found[0] if found else None + + +def test_사토가_있으면_줄이_선다() -> None: + row = 줄(volume_m3=126.63, distance_m=None) + assert row is not None and row["quantity"] == 126.63 + + +def test_거리를_안_정했으면_막고_사유() -> None: + """⚠ 품셈이 정하는 값이 아니다 — 임의 거리를 넣으면 그대로 금액이 된다.""" + row = 줄(volume_m3=126.63, distance_m=None) + assert row["blocked_kind"] == "input_missing" + assert row["in_bill"] is False + # ⚠ 문구가 **바뀐 자리** — 2026-09-09 사용자 확정으로 사토장이 **이미 있는 측점 위에만** + # 놓이게 되어 거리가 저절로 나온다. 그래서 「거리가 없다」가 아니라 「사토장을 안 놓았다」다. + assert "사토장을 아직 안 놓았고" in row["blocked_reason"] + + +def test_거리를_정하면_금액이_선다() -> None: + row = 줄(volume_m3=126.63, distance_m=1200.0) + assert row["work_item_code"] == load_mapping().for_haul("dump_truck")["work_item_code"] + assert row["haul_distance_m"] == 1200.0 + assert row["in_bill"] is True + assert row["blocked_kind"] is None + + +def test_자연방토는_뺀다() -> None: + """⚠ 자연방토는 실어 내지 않는 몫이다 — 함께 세면 운반이 부푼다.""" + got = _spoil_of({"haul_plan": {"spoil_m3": 200.0, "natural_spoil_m3": 50.0}}, {}) + assert got["volume_m3"] == 150.0 + assert got["distance_m"] is None + assert "자연방토" in got["note"] + + +def test_공제와_가산이_끝난_값을_그대로_쓴다() -> None: + """⚠ 여기서 또 빼거나 더하면 두 번 셈이다 — 근거 문구에만 적는다.""" + got = _spoil_of( + { + "haul_plan": { + "spoil_m3": 126.63, + "natural_spoil_m3": 0.0, + "structure_spoil_added_m3": 126.63, + "collected_stone_deducted_m3": 0.0, + } + }, + {"spoil_site_distance_m": 800.0}, + ) + assert got["volume_m3"] == 126.63 + assert got["distance_m"] == 800.0 + assert "구조물 잔토" in got["note"] + + +def test_사토가_없으면_줄도_없다() -> None: + assert 줄(volume_m3=0.0, distance_m=500.0) is None + + +def 줄들(**spoil: object) -> list[dict]: + rows = build_handoff(haul_table={"rows": [], "spoil": spoil})["work_items"] + return [row for row in rows if row["name"] == "사토 운반"] + + +def test_갈래마다_한_줄로_선다() -> None: + """⚠ 덤프 단가가 토사·암으로 갈린다 — 합쳐 세우면 한쪽 단가로 다 물린다.""" + rows = 줄들( + volume_m3=100.0, + distance_m=800.0, + by_ground_m3={"ea_m3": 40.0, "rr_m3": 60.0}, + ground_unknown_m3=0.0, + ) + assert {(row["ground_class"], row["quantity"]) for row in rows} == { + ("리핑암", 60.0), + ("토사", 40.0), + } + assert all(row["in_bill"] for row in rows) + + +def test_갈래를_못_붙인_몫은_따로_서고_막힌다() -> None: + """⚠ 토사로 눅이면 임의 단가가 된다 — 거리가 있어도 이 줄은 막는다.""" + rows = 줄들( + volume_m3=100.0, + distance_m=800.0, + by_ground_m3={"ea_m3": 70.0}, + ground_unknown_m3=30.0, + ) + unknown = next(row for row in rows if row["ground_class"] is None) + assert unknown["quantity"] == 30.0 + assert unknown["in_bill"] is False + assert "지반 갈래를 못 붙인" in unknown["blocked_reason"] + + +def test_갈래가_안_오면_종전처럼_한_줄() -> None: + """옛 저장분(갈래 없는 유토곡선 결과)에서도 줄이 사라지지 않는다.""" + rows = 줄들(volume_m3=61.88, distance_m=None) + assert len(rows) == 1 and rows[0]["quantity"] == 61.88 + + +def test_사토장_거리가_오면_그것이_이긴다() -> None: + """⚠ 정확한 값이 이긴다 — 설정의 대체 거리보다 **사토장 측점까지 누가거리**가 앞선다.""" + rows = 줄들( + volume_m3=100.0, + distance_m=800.0, + by_ground_m3={"ea_m3": 40.0, "rr_m3": 60.0}, + distance_by_ground_m={"ea_m3": 250.0, "rr_m3": 410.0}, + ground_unknown_m3=0.0, + ) + got = {row["ground_class"]: row for row in rows} + assert got["토사"]["haul_distance_m"] == 250.0 + assert got["리핑암"]["haul_distance_m"] == 410.0 + assert all(row["in_bill"] for row in rows) + assert "사토장 측점까지" in got["토사"]["spec_detail"] + + +def test_사토장_거리가_없는_갈래는_대체값으로_서고_그_사실이_적힌다() -> None: + rows = 줄들( + volume_m3=100.0, + distance_m=800.0, + by_ground_m3={"ea_m3": 40.0, "rr_m3": 60.0}, + distance_by_ground_m={"ea_m3": 250.0}, + ground_unknown_m3=0.0, + ) + got = {row["ground_class"]: row for row in rows} + assert got["리핑암"]["haul_distance_m"] == 800.0 + assert "대체 거리" in got["리핑암"]["spec_detail"] + + +def test_사토도_자연상태로_선다() -> None: + """⚠ 내역 수량은 자연상태 — 되돌린 값이 오면 그 값으로 선다(config 5-4-3).""" + rows = 줄들( + volume_m3=61.88, + distance_m=800.0, + by_ground_m3={"rr_m3": 61.88}, + natural_m3_by_ground={"rr_m3": 53.809}, + ground_unknown_m3=0.0, + ) + assert rows[0]["quantity"] == 53.809 + assert "자연상태" in rows[0]["spec_detail"] + + +def test_되돌린_값이_없으면_다짐_그대로_두고_사유() -> None: + rows = 줄들(volume_m3=61.88, distance_m=800.0, by_ground_m3={"rr_m3": 61.88}) + assert rows[0]["quantity"] == 61.88 + assert "다짐상태 그대로" in rows[0]["spec_detail"] diff --git a/resources/tester/test_b08_standard_slope.py b/resources/tester/test_b08_standard_slope.py new file mode 100644 index 00000000..5cf872cf --- /dev/null +++ b/resources/tester/test_b08_standard_slope.py @@ -0,0 +1,136 @@ +"""돌쌓기 표준경사 — 직고·메찰·성절토로 갈린다 (2026-09-09 사용자 확정 ⑨). + +⚠ 겨누는 것 여섯 + ① 표대로 갈린다 — 우리 종전 0.3 은 「메쌓기·성토·1.5m 이하」 한 칸이었다 + ② ⚠ 경계값은 **앞 칸** — 원문이 `∼3` 이라 직고 3.0m 은 `~3` 칸이다 + ③ 7m 초과라야 마지막 칸이다 + ④ 사용자가 정했으면 **그 값이 이긴다** + ⑤ 근거 문구에 **어느 칸에서 왔는지**와 **성토 열로 잠정임**이 적힌다 + ⑥ ⚠ 큰돌쌓기는 이 표의 대상이 아니다(교본 「1:0.3 이상」) +""" + +from __future__ import annotations + +import math +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + boulder_masonry, + face_slope_ratio, + stone_masonry, +) + + +def 기울기(wet: bool, height_m: float, face: str | None = "성토", **options: object) -> float: + """⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다 — + 시험에서는 그 자리를 명시해 표의 어느 열을 보는지 분명히 한다.""" + return face_slope_ratio(dict(options), wet=wet, height_m=height_m, face=face)[0] + + +def test_표대로_갈린다() -> None: + # 메쌓기 성토 + assert 기울기(False, 1.2) == 0.30 + assert 기울기(False, 2.0) == 0.35 + assert 기울기(False, 4.0) == 0.40 + assert 기울기(False, 6.0) == 0.45 + # 찰쌓기 성토 + assert 기울기(True, 1.2) == 0.25 + assert 기울기(True, 2.5) == 0.30 + assert 기울기(True, 4.0) == 0.35 + + +def test_경계값은_앞_칸이다() -> None: + """⚠ 원문 표기가 `∼1.5 · ∼3` 이라 **이하**로 읽는다 — 한 칸 밀리면 값이 갈린다.""" + assert 기울기(True, 1.5) == 0.25 # `~1.5` 칸 + assert 기울기(True, 1.51) == 0.30 # 다음 칸 + assert 기울기(False, 3.0) == 0.35 # `~3` 칸 + assert 기울기(False, 3.01) == 0.40 + + +def test_7m_초과라야_마지막_칸() -> None: + assert 기울기(False, 7.0) == 0.45 + assert 기울기(False, 7.01) == 0.50 + + +def test_사용자가_정하면_그_값이_이긴다() -> None: + ratio, basis = face_slope_ratio({"face_slope_ratio": 0.5}, wet=True, height_m=2.5, face="성토") + assert ratio == 0.5 + assert "사용자 지정" in basis + + +def test_근거에_칸과_잠정임이_적힌다() -> None: + _, basis = face_slope_ratio( + {}, wet=False, height_m=2.0, face="성토", face_reason="right_cut · 좌측 → 성토면" + ) + assert "13-4-4" in basis and "메쌓기" in basis + # ⚠ 어느 단면유형에서 왔는지가 근거에 실린다 — 값만 있고 까닭이 없으면 못 되짚는다. + assert "right_cut" in basis + + +def test_면적이_그_기울기로_선다() -> None: + """메쌓기 H=2.0 은 1:0.35 — 종전 0.3 보다 **면적이 커진다**.""" + components, _ = stone_masonry( + 2.0, 10.0, {"height_m": 2.0, "length_m": 10.0, "back_len_cm": 35}, False, "성토" + ) + area = next(c for c in components if c.name == "돌쌓기") + assert area.amount == 20.0 * math.hypot(1.0, 0.35) + assert "표준경사" in area.basis + + +def test_큰돌쌓기는_이_표의_대상이_아니다() -> None: + """교본이 「1:0.3 **이상**」으로만 두었다 — 직고로 갈리지 않는다.""" + components, _ = boulder_masonry( + 5.0, 10.0, {"height_m": 5.0, "length_m": 10.0, "stone_cm": "60~80"} + ) + area = next(c for c in components if c.name == "큰돌쌓기") + assert area.amount == 50.0 * math.hypot(1.0, 0.3) + + +def test_표_밖_값도_그대로_쓴다() -> None: + """⚠ 실무 도면에 **S0.7·0.8** 이 실재한다(품셈 표는 0.20~0.50). + + 표 밖이라고 막거나 가까운 칸으로 **접지 않는다** — 접으면 사용자가 넣은 값이 조용히 + 다른 값으로 돌아간다(뒷길이에서 이미 겪은 자리). 「사용자 지정」으로 그대로 쓴다. + """ + for value in (0.7, 0.8, 1.2): + ratio, basis = face_slope_ratio( + {"face_slope_ratio": value}, wet=True, height_m=2.5, face="성토" + ) + assert ratio == value + assert "사용자 지정" in basis + + components, _ = stone_masonry( + 2.5, + 10.0, + {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "face_slope_ratio": 0.7}, + wet=True, + ) + area = next(c for c in components if c.name == "돌쌓기") + assert area.amount == 25.0 * math.hypot(1.0, 0.7) + + +def test_성절토를_못_가르면_눅이지_않는다() -> None: + """⚠ `both_cut` + 「자동」처럼 **가를 근거가 없는 자리**가 있다(판정 한 벌이 `None` 을 줌). + + 성토로 눅여 표를 고르면 **임의값이 금액으로 굳는다.** 종전값으로 서되 **왜 못 갈랐는지**를 + 근거에 적어 사용자가 무엇이 없는지 보게 한다. + """ + ratio, basis = face_slope_ratio( + {}, wet=True, height_m=2.5, face=None, face_reason="both_cut · 자동 — 세울 성토면이 없음" + ) + assert ratio == 0.3 + assert "both_cut" in basis and "종전값" in basis + + +def test_판정_한_벌을_그대로_쓴다() -> None: + """⚠ 판정을 두 벌로 짜면 예외에서 갈린다 — 공용 함수 결과가 그대로 흘러야 한다.""" + from common_util.common_util_structure_face_role import structure_face_role + + role, reason = structure_face_role("right_cut", "좌") + assert role == "성토" + _, basis = face_slope_ratio({}, wet=True, height_m=2.5, face=role, face_reason=reason) + assert reason in basis diff --git a/resources/tester/test_b08_stone_kind.py b/resources/tester/test_b08_stone_kind.py new file mode 100644 index 00000000..65d29da6 --- /dev/null +++ b/resources/tester/test_b08_stone_kind.py @@ -0,0 +1,122 @@ +"""돌 종류 축 — 계수 셋이 그 축으로 갈리는지 (2026-09-08). + +⚠ 겨누는 것 다섯 + ① 안 고르면 **종전 값 그대로**인가 (저장된 프로젝트가 안 흔들리게) + ② 고르면 산림품셈 계수로 바뀌는가 + ③ ⚠ 뒤채움 몫을 **뒤집어 쓰지 않는가** — 표는 「뒤채움」, 우리 식은 「빼는 몫」 + ④ 표에 없는 칸(「-」)을 **지어내지 않는가** + ⑤ 모르는 종류를 조용히 넘기지 않는가 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + load_stone_kind_table, + stone_masonry, +) + +#: 고치기 전 값 — 이 셋이 바뀌면 이미 저장된 프로젝트의 수량이 흔들린 것이다. +#: ⚠ 막자갈이 두 번 갈렸다 — 15.130 → 12.630(2026-09-08 확정 ② 벽 두께 실무 구조물도 식) +#: → **9.375**(2026-09-09 확정 5차 작은 것 3, 랩탑 보조). 지금은 「입적 − 몸통」이 아니라 +#: **뒷채움 사다리꼴**(정본 상 0.30 · 하 0.45)이라 **돌 종류·뒷길이를 아예 안 본다.** +#: 고임돌·채움콘크리트는 여전히 비탈면적 × 종류별 계수다. +BEFORE = {"고임돌": 3.915, "채움콘크리트": 5.220, "막자갈": 9.375} + + +def 성분(options: dict) -> dict: + components, notes = stone_masonry( + 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, **options}, wet=True + ) + return {c.name: round(c.amount, 3) for c in components}, notes + + +def test_안_고르면_종전_값_그대로() -> None: + """⚠ 값을 못 낸다고 멈추면 이미 저장된 프로젝트가 통째로 빈다.""" + got, notes = 성분({"back_len_cm": 45}) + for name, before in BEFORE.items(): + assert abs(got[name] - before) < 0.002, f"{name} 이 {before} → {got[name]} 로 바뀌었다" + assert any("돌 종류를 안 정해" in n for n in notes), "기본값으로 선 사실을 안 알린다" + + +def test_야면석을_고르면_계수가_바뀐다() -> None: + """품셈 13-4-3 야면석 0.11 · 13-4-4 [주]① 야면석 0.15 (뒷길이 45).""" + got, notes = 성분({"back_len_cm": 45, "stone_kind": "야면석·호박돌"}) + assert abs(got["고임돌"] - 26.101 * 0.11) < 0.01 + assert abs(got["채움콘크리트"] - 26.101 * 0.15) < 0.01 + # ⚠ 사유는 여러 갈래가 함께 뜬다(기초 유/무 등) — **돌 종류 사유만** 본다. + assert all("돌 종류를 안 정해" not in n for n in notes) + + +def test_깬돌은_종전과_같은_계수() -> None: + """⚠ 종전 값이 「깬돌」 줄이었다 — 그것이 확인되면 지금까지 값의 성격이 밝혀진다.""" + got, _ = 성분({"back_len_cm": 45, "stone_kind": "깬돌"}) + assert abs(got["고임돌"] - BEFORE["고임돌"]) < 0.002 + assert abs(got["채움콘크리트"] - BEFORE["채움콘크리트"]) < 0.002 + + +def test_막자갈은_이제_돌_종류_축이_아니다() -> None: + """⚠ 뒤집힌 시험이다 — 종전에는 「깬돌 막자갈 > 야면석 막자갈」을 겨눴다. + + 그 시험은 막자갈이 「입적 − 몸통」이던 시절 **뒤채움 몫을 뒤집어 쓰는 것**을 막으려던 + 것이었다(표는 뒤채움 몫, 우리 식은 빼는 몫 — 반대라 19.045 가 나온 적 있다). + 2026-09-09 확정 5차로 막자갈이 **뒷채움 사다리꼴**로 갈리면서 그 축이 사라졌다 — + 이제 뒤집어 쓸 몫 자체가 없다. 대신 **종류로 안 갈린다는 것**을 못 박는다. + """ + 야면석, _ = 성분({"back_len_cm": 45, "stone_kind": "야면석·호박돌"}) + 깬돌, _ = 성분({"back_len_cm": 45, "stone_kind": "깬돌"}) + assert 깬돌["막자갈"] == 야면석["막자갈"] == BEFORE["막자갈"] + # ⚠ 갈리는 줄은 그대로 갈려야 한다 — 축이 통째로 죽은 것이 아니다. + assert 깬돌["고임돌"] > 야면석["고임돌"] + + +def test_표에_없는_칸은_지어내지_않는다() -> None: + """⚠ 견치돌은 뒷길이 25·30 이 원문에서 「-」다 — 그 규격에 그 돌을 안 쓴다는 뜻.""" + table = load_stone_kind_table() + assert table["wedge_stone_m3_per_m2"]["견치돌"]["25"] is None + assert table["wedge_stone_m3_per_m2"]["야면석·호박돌"]["75"] is None + + +def test_모르는_종류는_조용히_넘어가지_않는다() -> None: + got, notes = 성분({"back_len_cm": 45, "stone_kind": "화강석"}) + assert any("아는 돌 종류가 아니라" in n for n in notes) + # 값은 종전대로 서되 사유가 남는다 — 0 으로 때우지 않는다. + assert got["고임돌"] > 0 + + +def test_산출_근거에_돌_종류가_적힌다() -> None: + """⚠ 값만 바뀌고 근거가 그대로면 화면에서 왜 달라졌는지 못 짚는다.""" + components, _ = stone_masonry( + 2.5, + 10.0, + {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "stone_kind": "깬돌"}, + wet=True, + ) + # ⚠ 막자갈은 이제 종류를 안 본다 — 계수가 종류로 갈리는 **두 줄**을 본다. + # 채움콘크리트는 값이 갈리는데 근거에 종류가 안 적히던 자리다(2026-09-09 맞춤). + for name in ("고임돌", "채움콘크리트"): + got = next(c for c in components if c.name == name) + assert "깬돌" in got.basis, name + + +def test_고른_종류의_빈_칸은_줄을_안_만든다() -> None: + """⚠ 원문 「-」인 칸에 값이 서던 자리(만들다 잡음). + + 야면석 75㎝ 는 13-4-3 에 「-」다 — `None` 이라고 안 덮으면 종전 값(깬돌 0.25)이 남아 + **원문에 없는 값이 조용히 선다.** 0 줄로 만들지도 않는다 — 0 은 「없음」과 구별이 안 된다. + """ + got, notes = 성분({"back_len_cm": 75, "stone_kind": "야면석·호박돌"}) + assert "고임돌" not in got + assert any("「-」" in n or "칸이 비어" in n for n in notes) + + +def test_뒷길이_일곱_규격이_다_선다() -> None: + """⚠ 앞서 네 칸만 들고 25·30·75 를 접고 있었다.""" + for back in (25, 30, 35, 45, 55, 60, 75): + got, _ = 성분({"back_len_cm": back, "stone_kind": "깬잡석"}) + assert got.get("채움콘크리트", 0) > 0, back diff --git a/resources/tester/test_b08_structure_trench_rows.py b/resources/tester/test_b08_structure_trench_rows.py new file mode 100644 index 00000000..dc514046 --- /dev/null +++ b/resources/tester/test_b08_structure_trench_rows.py @@ -0,0 +1,245 @@ +"""구조물 터파기·되메우기·잔토가 인계 축에 오르나 (2026-09-08). + +⚠⚠ **빠뜨렸던 자리다.** 성분으로만 있고 아무도 안 받아 **내역서에 한 줄도 안 나갔다** — + 두께 식·기초 몫을 아무리 맞춰도 금액이 0원이었다(B09 매김에서 드러남). + +⚠ 겨누는 것 여섯 + ① 터파기가 **공종 줄로 선다** + ② 심도로 갈린다 — 품셈 9-13 이 18구분(토질 3 × 육상/용수 × 심도 3)이라서 + ③ ⚠ 토질·용수를 **지어내지 않는다** — 상위 코드 + 사유 + ④ 되메우기는 코드가 붙고 **금액이 선다** + ⑤ ⚠ 잔토는 **사토로 갈 몫**이라 내역에 안 서되 값과 사유가 실린다 + ⑥ 구조물이 없으면 줄도 없다 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + + +def 구조물(**options: object) -> dict: + return { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, + } + + +def 줄(**options: object) -> dict: + unit = build_table([구조물(**options)]) + rows = build_handoff(unit_quantity_table=unit)["work_items"] + return {row["name"]: row for row in rows} + + +def test_터파기가_공종_줄로_선다() -> None: + row = 줄(foundation="기초유")["구조물터파기"] + assert row["unit"] == "㎥" + assert row["quantity"] > 0 + assert row["work_item_code"] == "FP-09-13" + + +def test_심도로_갈린다() -> None: + """직고 2.5 + 기초 0.5 = 3.0m → `2~3m` 칸. 기초를 안 고르면 2.5m 라 같은 칸이다.""" + assert 줄(foundation="기초유")["구조물터파기"]["spec"] == "심도 2~3m" + 깊은 = build_table( + [ + { + "structure_id": "s9", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": { + "height_m": 3.5, + "length_m": 10.0, + "back_len_cm": 45, + "foundation": "기초유", + }, + } + ] + ) + rows = build_handoff(unit_quantity_table=깊은)["work_items"] + assert next(r for r in rows if r["name"] == "구조물터파기")["spec"] == "심도 3m 초과" + + +def test_토질을_못_가르면_그렇게_적는다() -> None: + """⚠ 지반 갈래 표가 없으면 「토질을 못 가름」이다 — 토사로 눅이지 않는다.""" + row = 줄(foundation="기초유")["구조물터파기"] + assert row["blocked_kind"] == "input_missing" + assert "토질을 못 가름" in row["blocked_reason"] + assert row["ground_class"] is None + assert row["in_bill"] is False + + +def test_토질은_측점_설계값에서_온다() -> None: + """⚠ 새 칸을 만들지 않는다 — 이미 저장되는 `design.ground_type` 을 읽는다.""" + unit = build_table( + [구조물(foundation="기초유")], + None, + None, + {0.0: "ripping_rock", 5.0: "ripping_rock", 10.0: "ripping_rock"}, + ) + row = next( + r + for r in build_handoff(unit_quantity_table=unit)["work_items"] + if r["name"] == "구조물터파기" + ) + assert row["ground_class"] == "암절취" + assert row["spec"] == "암절취 · 심도 2~3m" + # 남은 축은 용수 하나다 — 그것만 사유에 남는다. + assert "용수" in row["blocked_reason"] and "토질" not in row["blocked_reason"] + + +def test_걸친_측점이_섞이면_안_고른다() -> None: + """⚠ 다수결로 고르면 임의값이 금액으로 굳는다 — 암 단가가 몇 배다.""" + unit = build_table( + [구조물(foundation="기초유")], + None, + None, + {0.0: "soil", 5.0: "soil", 10.0: "blasting_rock"}, + ) + row = next( + r + for r in build_handoff(unit_quantity_table=unit)["work_items"] + if r["name"] == "구조물터파기" + ) + assert row["ground_class"] is None + assert "섞여" in row["blocked_reason"] + assert "토사 2곳" in row["blocked_reason"] and "발파암 1곳" in row["blocked_reason"] + + +def test_되메우기는_금액이_선다() -> None: + row = 줄(foundation="기초유")["되메우기"] + assert row["work_item_code"] == "FP-09-14-01" + assert row["in_bill"] is True + assert abs(row["quantity"] - (0.5 + 2.5) * 0.2 * 10.0) < 1e-9 + + +def test_잔토는_사토로_갈_몫이라_내역에_안_선다() -> None: + """⚠ 유토곡선이 세야 겹치지 않는다 — 다만 그 통로가 아직 없다는 사실을 사유로 남긴다.""" + row = 줄(foundation="기초유")["잔토처리"] + assert row["in_bill"] is False + # ⚠ 「통로가 없어 안 실림」이던 문구가 **바뀐 자리** — 2026-09-09 유토곡선이 그 값을 + # 받아 사토에 얹고 「사토 운반」 줄로 세우게 됐다. 사유도 그 사실로 옮겼다. + assert "사토" in row["in_bill_reason"] and "사토 운반" in row["in_bill_reason"] + assert row["quantity"] > 0 + + +def test_구조물이_없으면_줄도_없다() -> None: + rows = build_handoff(unit_quantity_table={"structures": []})["work_items"] + assert all(row["name"] not in {"구조물터파기", "되메우기", "잔토처리"} for row in rows) + + +def test_같은_토질_같은_심도는_한_줄이다() -> None: + """⚠ 근거 문구가 측점마다 달라 **두 줄로 갈리던 자리**(2026-09-08 실화면). + + 사람이 보는 문구가 다르다는 이유로 내역 줄이 쪼개지면 같은 공종이 두 번 선다. + """ + unit = build_table( + [ + { + "structure_id": "a", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": { + "height_m": 2.5, + "length_m": 10.0, + "back_len_cm": 45, + "foundation": "기초유", + }, + }, + { + "structure_id": "b", + "type_id": "masonry_wet", + "start_m": 100.0, + "end_m": 110.0, + "options": { + "height_m": 2.5, + "length_m": 10.0, + "back_len_cm": 45, + "foundation": "기초유", + }, + }, + ], + None, + None, + {0.0: "soil", 10.0: "soil", 105.0: "soil"}, + ) + rows = [ + r + for r in build_handoff(unit_quantity_table=unit)["work_items"] + if r["name"] == "구조물터파기" + ] + assert len(rows) == 1 + assert rows[0]["ground_class"] == "토사" + + +# ── 용수 축 · 18구분 (2026-09-09 사용자 확정 3차 ④) ────────────────── + + +def test_18구분_차례가_원문_그대로다() -> None: + """⚠ 한 칸 밀리면 **다른 지반·다른 심도 단가**가 붙는다.""" + from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import trench_child_code + + assert trench_child_code("FP-09-13", "soil", "육상", "0~1m") == "FP-09-13-01" + assert trench_child_code("FP-09-13", "soil", "용수", "0~1m") == "FP-09-13-04" + assert trench_child_code("FP-09-13", "ripping_rock", "육상", "0~1m") == "FP-09-13-07" + assert trench_child_code("FP-09-13", "ripping_rock", "용수", "2~3m") == "FP-09-13-12" + assert trench_child_code("FP-09-13", "blasting_rock", "용수", "2~3m") == "FP-09-13-18" + + +def test_축이_없으면_상위_코드로_둔다() -> None: + """⚠ 3m 를 넘는 칸은 **원문에 없다** — 지어내지 않는다.""" + from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import trench_child_code + + assert trench_child_code("FP-09-13", "soil", "", "0~1m") is None + assert trench_child_code("FP-09-13", None, "육상", "0~1m") is None + assert trench_child_code("FP-09-13", "soil", "육상", "3m 초과") is None + + +def 물줄(water: str | None, ground: str = "ripping_rock") -> dict: + unit = build_table( + [구조물(foundation="기초유")], + None, + None, + {0.0: ground, 10.0: ground}, + ) + rows = build_handoff(unit_quantity_table=unit, structure_trench_water=water)["work_items"] + return next(row for row in rows if row["name"] == "구조물터파기") + + +def test_용수를_정하면_자식_코드로_내려가_금액이_선다() -> None: + row = 물줄("육상") + assert row["work_item_code"] == "FP-09-13-09" # 육상 암절취 2~3m + assert row["in_bill"] is True and row["blocked_kind"] is None + + +def test_통상값이라는_사실이_줄에_적힌다() -> None: + """⚠ 사용자 확정이 아니라 **통상값**이다 — 안 적으면 뒤집을 근거가 사라진다.""" + assert "통상값" in 물줄("육상")["spec_detail"] + + +def test_용수로_뒤집으면_코드가_옮겨_간다() -> None: + assert 물줄("용수")["work_item_code"] == "FP-09-13-12" + + +def test_안_정하면_상위_코드로_막힌다() -> None: + row = 물줄(None) + assert row["work_item_code"] == "FP-09-13" + assert row["in_bill"] is False and "용수" in row["blocked_reason"] + + +def test_상태_사실이_값_옆에_적힌다() -> None: + """⚠ 코드 주석에만 두면 화면에서 안 보인다 — 되메우기·잔토 줄에 함께 실린다.""" + rows = 줄(foundation="기초유") + for name in ("되메우기", "잔토처리"): + assert "다짐부피 ÷ C" in rows[name]["spec_detail"], name diff --git a/resources/tester/test_b08_topsoil_haul.py b/resources/tester/test_b08_topsoil_haul.py new file mode 100644 index 00000000..e6c6e49d --- /dev/null +++ b/resources/tester/test_b08_topsoil_haul.py @@ -0,0 +1,192 @@ +"""표토 운반·적치 — 법이 요구하는데 제거만 세고 있던 줄 (2026-09-09). + +⚠⚠ 시행규칙 별표2 Ⅰ.2.차.(6)·Ⅰ.3.카.(6): + 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나 + 경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」 + ⇒ 제거(9-15)만 세면 **운반이 빠진다.** + +⚠ 겨누는 것 다섯 + ① 줄이 **선다** — 값이 없어도 목록에 남는다 + ② 물량은 **제거 물량 그대로** — 다시 세지 않는다 + ③ 거리는 **설계 입력** — 「최고 홍수위보다 높은 장소」는 현장이 정한다. 지어내지 않는다 + ④ ⚠ 제거가 안 서면(두께 미입력) 운반도 안 선다 — 밑수가 그 줄이다 + ⑤ 법 문구가 **사유에 적힌다** — 왜 세는지 화면에서 보여야 한다 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 + STATUS_PENDING, + STATUS_READY, + preparation_rows, +) + +사면 = {"face_dressing_fill": 600.0, "face_dressing_cut": 400.0} + + +def 줄(thickness: float | None, distance: float | None) -> dict: + rows = preparation_rows(사면, [], thickness, distance) + return {row["item"]: row for row in rows} + + +def test_줄이_선다() -> None: + assert "표토 운반·적치" in 줄(None, None) + + +def test_물량은_제거_물량_그대로() -> None: + """⚠ 다시 세면 두 값이 갈린다 — 제거 줄이 밑수다.""" + rows = 줄(0.2, 300.0) + assert rows["표토제거"]["amount"] == 1000.0 * 0.2 + assert rows["표토 운반·적치"]["amount"] == rows["표토제거"]["amount"] + assert rows["표토 운반·적치"]["status"] == STATUS_READY + assert rows["표토 운반·적치"]["work_item_code"] == "FP-10-12" + + +def test_거리를_안_넣으면_막고_사유() -> None: + """⚠ 「최고 홍수위보다 높은 장소」는 현장이 정한다 — 품셈이 거리를 주지 않는다.""" + row = 줄(0.2, None)["표토 운반·적치"] + assert row["amount"] is None and row["status"] == STATUS_PENDING + assert "운반거리가 아직 입력되지 않았습니다" in row["reason"] + # 값을 버리지 않는다 — 운반할 물량은 참고로 보인다. + assert row["reference_amount"] == 200.0 + + +def test_제거가_안_서면_운반도_안_선다() -> None: + row = 줄(None, 300.0)["표토 운반·적치"] + assert row["amount"] is None + assert "두께 먼저" in row["reason"] + + +def test_법_문구가_사유에_적힌다() -> None: + for thickness, distance in ((None, None), (0.2, None), (0.2, 300.0)): + reason = 줄(thickness, distance)["표토 운반·적치"]["reason"] + assert "별표2" in reason and "운반하고 쌓아두어야" in reason + + +# ── 뿌리 적재·운반 — 품셈이 네 단계로 둔 자리 (2026-09-09) ────────────── + + +def test_뿌리_뒤_단계_두_줄이_선다() -> None: + """⚠⚠ 「벌개·제근 → 뿌리다듬기 → **적재** → **운반**」인데 우리는 한 줄만 세고 있었다. + + 표토와 같은 병이다 — 법·품셈이 두 동작을 묶어 두는데 앞 동작만 세던 자리. + """ + rows = 줄(0.2, 300.0) + assert "뿌리 적재" in rows and "뿌리 운반" in rows + + +def test_적재는_9_20_2_운반은_덤프() -> None: + """⭐ 2026-09-09 확정 5차 4번 — 「뿌리 운반은 **덤프**」로 답이 왔다. + + ⚠ 그 전에는 「9-20 장에 운반 공종이 없어 코드를 못 붙임」이었다. 지어낸 것이 아니라 + **사용자가 고른 것**이라 코드가 붙는다. + ⚠ 다만 물량은 여전히 안 선다 — 운반 밑수는 **부피(㎥)** 인데 뿌리 부피를 든 곳이 없다. + """ + rows = 줄(0.2, 300.0) + assert rows["뿌리 적재"]["work_item_code"] == "FP-09-20-02" + assert rows["뿌리 운반"]["work_item_code"] == "FP-10-12" + assert rows["뿌리 운반"]["amount"] is None + assert "부피" in rows["뿌리 운반"]["reason"] + + +def test_적재는_면적으로_서고_운반은_못_선다() -> None: + """⭐ 확정 5차 6번으로 **면적 축**이 정해져 적재는 선다(제근과 같은 밑수). + + ⚠ 운반만 못 선다 — 축이 **부피**라서다. 「같은 확정인데 한 줄만 서는」 자리라 갈라 적는다. + ⚠ 대상 면적은 **벌개제근 연동**이라 `tree_removal_*` 에서 온다(표토 계열 `face_dressing_*` + 과 **다른 면적**이다 — 2026-09-09 실측: 17,873.8 ↔ 15,726.9). + """ + 사면 = {**{"tree_removal_fill": 600.0, "tree_removal_cut": 400.0}, **{}} + rows = {row["item"]: row for row in preparation_rows(사면, [], 0.2, 300.0)} + assert rows["뿌리 적재"]["amount"] == 1000.0 + assert rows["제근·뿌리다듬기"]["amount"] == 1000.0 # 같은 밑수로 함께 섬 + assert rows["뿌리 운반"]["amount"] is None + assert rows["뿌리 운반"]["reference_amount"] == 1000.0 + + +# ── 2026-09-09 준비공 축 감사에서 잡은 둘 ────────────────────────── + + +def test_면적이_0이면_물량을_안_낸다() -> None: + """⚠ 0 은 **「없음」과 구별이 안 된다** — 받는 쪽이 「표토가 없는 노선」으로 읽는다. + + 절·성토 사면적이 0 인 임도는 성립하지 않으므로, 이 자리는 사실상 **사면표가 아직 + 안 선 것**이다. 두께 미입력은 이미 막고 있었는데 면적 0 은 그냥 통과하고 있었다. + """ + from B08_Quantity.B08_Quantity_Engine_Preparation import build_table + + rows = {r["item"]: r for r in build_table(slope_totals={}, topsoil_thickness_m=0.2)["rows"]} + 제거 = rows["표토제거"] + assert 제거["amount"] is None + assert "사면표" in 제거["reason"] + # 운반은 제거를 밑수로 삼으므로 함께 막힌다 — 0 ㎥ 를 실어 나르는 줄이 안 생긴다. + assert rows["표토 운반·적치"]["amount"] is None + + +def test_대상_면적이_법_문언과_다르다는_사실이_적힌다() -> None: + """⚠ 값을 고치는 것이 아니라 **드러내는** 시험이다(임의 확정 금지). + + 별표2 Ⅰ.2.차.(6) 은 대상을 「**노면·절토대상지**」로 못박고, 성토대상지는 (7) 에서 + 「표토 등은 **제거·정리**한다」로 따로 두어 운반·적치 의무를 안 건다. 우리는 + 절토·성토 **사면적을 다 더하고 노면은 안 센다** — 두 방향으로 어긋난다. + 실무가 어느 쪽인지는 사용자·실무자 확인 사항이라 **사유만** 낸다. + """ + from B08_Quantity.B08_Quantity_Engine_Preparation import build_table + + rows = { + r["item"]: r + for r in build_table( + slope_totals={"face_dressing_cut": 9000.0, "face_dressing_fill": 6726.9}, + topsoil_thickness_m=0.2, + )["rows"] + } + reason = rows["표토제거"]["reason"] + assert rows["표토제거"]["amount"] is not None # 값은 그대로 선다 + assert "노면·절토대상지" in reason and "노면은 안 셈" in reason + + +def test_운반이_갈래를_실어_보낸다() -> None: + """⚠ `FP-10-12` 는 **부모**다 — 품이 붙은 것은 잎(10-12-1 토사 · -2 암절취 · -3 발파암)뿐. + + 갈래를 안 보내면 B09 가 「이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보 3건」 + 에서 멈추고 **금액이 안 선다.** 표토가 토사인 것은 다툼이 없으므로 갈래를 실어 보낸다. + """ + from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff + from B08_Quantity.B08_Quantity_Engine_Preparation import build_table + + table = build_table( + slope_totals={"face_dressing_cut": 9000.0, "face_dressing_fill": 6726.9}, + topsoil_thickness_m=0.2, + topsoil_haul_distance_m=500.0, + ) + handed = {r["name"]: r for r in build_handoff(preparation_table=table)["work_items"]} + 운반 = handed["표토 운반·적치"] + assert 운반["in_bill"] is True + assert (운반["variant_axis"], 운반["variant_value"]) == ("ground_class", "토사") + + +def test_제거_품에_20m_압토가_들어_있다는_사실이_적힌다() -> None: + """⚠ 품셈 9-15-2 원문이 `L(운반거리) 20m` 를 적용값으로 박아 뒀다. + + 실무 내역서(영월 2024)도 「표토제거 답외구간 / M2 · 도자 19Ton · D=20」 **한 줄뿐이고 + 운반 줄이 따로 없다.** 우리 운반 줄은 그래서 **20m 를 넘는 몫**일 때만 새 줄인데 그 + 가름을 아직 안 했다 — 값을 지어내지 않고 **그 사실을 사유에 남긴다**(이중계상 후보). + """ + from B08_Quantity.B08_Quantity_Engine_Preparation import build_table + + rows = { + r["item"]: r + for r in build_table( + slope_totals={"face_dressing_cut": 9000.0, "face_dressing_fill": 6726.9}, + topsoil_thickness_m=0.2, + topsoil_haul_distance_m=500.0, + )["rows"] + } + reason = rows["표토 운반·적치"]["reason"] + assert "L=20m 압토가 들어 있음" in reason and "20m 를 넘는" in reason diff --git a/resources/tester/test_b08_tree_removal_split.py b/resources/tester/test_b08_tree_removal_split.py new file mode 100644 index 00000000..159a6513 --- /dev/null +++ b/resources/tester/test_b08_tree_removal_split.py @@ -0,0 +1,77 @@ +"""지장목제거를 두 줄로 가름 (2026-09-09 사용자 확정 5차 2번). + +실무(영월 설계내역서 1.9)는 **한 면적에 두 작업**을 얹는다 — + 1.9.1 뿌리뽑기(장비+인력) 11,035㎡ @475 + 1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← **같은 11,035㎡** + +⚠ 겨누는 것 다섯 + ① 두 줄이 선다 + ② ⚠ **같은 면적**이 두 줄에 그대로 들어간다 — **이중계상이 아니다**(다른 작업이 얹히는 것) + ③ 그 사실이 사유에 적힌다 + ④ ⚠ 작업 갈래(뿌리뽑기·잡관목제거)를 **지반 갈래로 읽지 않는다** — 읽으면 + 「시공법 미지정으로 공종을 못 고름」이라는 **틀린 사유**가 붙는다 + ⑤ 잡관목제거는 **품셈에 이름이 없음**이 사유에 남는다(공종 보류 — 확정 5차 3번) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 + SummaryInput, + build_table, +) +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 + +사면 = {"tree_removal_fill": 9000.0, "tree_removal_cut": 8873.8} + + +def 집계() -> dict: + return build_table( + SummaryInput( + earthwork_totals={}, + slope_totals=사면, + haul_rows=[], + rock_classes=["토사"], + rock_ratios_pct={}, + application_ratios={}, + ) + ) + + +def 줄들() -> list[dict]: + return [row for row in build_handoff(summary_table=집계())["work_items"] if row["name"] == "지장목제거"] + + +def test_두_줄이_선다() -> None: + assert [row["spec"] for row in 줄들()] == ["뿌리뽑기", "잡관목제거"] + + +def test_같은_면적이_두_줄에_들어간다() -> None: + """⚠ 이중계상이 아니다 — 한 면적에 **다른 두 작업**이 얹히는 실무 서식이다.""" + amounts = {row["quantity"] for row in 줄들()} + assert amounts == {17873.8} + + +def test_이중계상이_아님이_사유에_적힌다() -> None: + for row in 줄들(): + assert "이중계상 아님" in row["in_bill_reason"] or "이중계상 아님" in str(row["spec_detail"]) + + +def test_작업_갈래를_지반_갈래로_읽지_않는다() -> None: + """⚠ 읽으면 「시공법 미지정으로 공종을 못 고름」이라는 틀린 사유가 붙는다.""" + for row in 줄들(): + assert row["ground_class"] is None + unmatched = build_handoff(summary_table=집계())["unmatched_work_items"] + assert "지장목제거" in unmatched + assert not any("지장목제거(" in item for item in unmatched) + + +def test_잡관목제거는_품셈에_이름이_없음이_남는다() -> None: + 잡관목 = next(row for row in 줄들() if row["spec"] == "잡관목제거") + 사유 = str(잡관목["spec_detail"]) + str(잡관목["in_bill_reason"]) + assert "품셈에 그 이름이 없어" in 사유 diff --git a/resources/tester/test_b08_unit_quantity.py b/resources/tester/test_b08_unit_quantity.py new file mode 100644 index 00000000..2a90299c --- /dev/null +++ b/resources/tester/test_b08_unit_quantity.py @@ -0,0 +1,410 @@ +"""구조물 원단위 전개식 검사 — PLAN 8-6·8-8·8-15. + +이 일감의 진짜 위험은 계산이 아니라 **이중계상**이다. 그래서 시험도 거기에 무게를 둔다. + ㉢ 배합(시멘트·모래)을 여기서 쪼개면 B09 일위대가와 겹쳐 두 배가 된다. + ㉠ 할증을 여기서 붙이면 자재총괄과 겹친다. + · 터파기·되메우기는 토공으로 합산되는 값이라 내역 줄과 구분돼야 한다. + +실무 관측값(울진 13종)은 **검산 정답지**이지 맞춰야 할 목표가 아니다 — 크게 벌어지면 +전개식을 의심하되, 맞추려고 식을 비틀지는 않는다. +""" + +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_UnitQuantity import ( # noqa: E402 + MIX_COMPONENTS, + STONE_BACK_LENGTH_TABLE, + Component, + StructureQuantity, + build_table, + expand, + stone_masonry, + verify_no_mix_components, +) + + +def 돌쌓기찰(height: float = 1.5, length: float = 1.0, **options) -> dict: + """저장된 제원 모양 그대로 — `structures.json` 의 한 항목.""" + return { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 35.0, + "end_m": 35.0 + length, + "options": {"height_m": height, "length_m": length, **options}, + } + + +def 성분(result: StructureQuantity, name: str) -> Component: + return next(c for c in result.components if c.name == name) + + +# ── ㉢ 배합 분해 금지 — 이 일감 최대 위험 ──────────────────────────── + + +def test_배합_성분이_산출물에_없을것() -> None: + """모르터·콘크리트까지만 낸다 — 시멘트·모래는 B09 일위대가 몫이다. + + ⚠ **정확히 같은 이름**으로 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 + `자갈` 로 오탐된다 — 개발 중 실제로 걸렸던 자리라 시험도 같은 규칙으로 둔다. + """ + result = expand(돌쌓기찰()) + names = {c.name for c in result.components} + assert not (names & MIX_COMPONENTS), f"배합 성분이 섞였다: {names & MIX_COMPONENTS}" + assert "막자갈" in names # 뒤채움 재료는 배합이 아니라 남아 있어야 한다 + + +def test_모르터까지만_내고_멈춤() -> None: + result = expand(돌쌓기찰()) + assert 성분(result, "모르터").unit == "㎥" + assert 성분(result, "채움콘크리트").unit == "㎥" + + +def test_배합이_섞이면_검사가_잡을것() -> None: + """실무 라이브러리를 베끼다 딸려 들어오기 쉬운 자리라 코드로 막는다.""" + tainted = StructureQuantity( + structure_id="x", + type_id="masonry_wet", + name="돌쌓기(찰)", + components=[Component("시멘트", "대", 2.6, "material")], + ) + assert verify_no_mix_components([tainted]) + assert not verify_no_mix_components([expand(돌쌓기찰())]) + + +def test_표에도_위반이_드러남() -> None: + table = build_table([돌쌓기찰()]) + assert table["mix_components_found"] == [] + + +# ── ㉠ 할증 금지 ──────────────────────────────────────────────────── + + +def test_할증_전_값임을_못박음() -> None: + """할증은 자재총괄 한 곳뿐이다(PLAN 8-7 ㉠).""" + assert build_table([돌쌓기찰()])["surcharge_applied"] is False + + +# ── 터파기·되메우기는 토공으로 합산 ───────────────────────────────── + + +def test_성분마다_갈_곳이_표시됨() -> None: + """내역 줄의 실체는 작업 공종이고, 터파기는 토공으로 합쳐진다(울진 D12~D14 실증).""" + result = expand(돌쌓기찰()) + assert 성분(result, "터파기").destination == "earthwork" + assert 성분(result, "되메우기").destination == "earthwork" + assert 성분(result, "잔토처리").destination == "earthwork" + assert 성분(result, "돌쌓기").destination == "unit_price" + # ⚠ 이름이 「야면석」 → 「돌」로 바뀜(2026-09-09 확정 5차 큰 것 7) — 종류를 안 고르면 + # 관측표가 아니라 **계산식**으로 서고 줄 이름도 정본 계산표대로 「돌」이다. + assert 성분(result, "돌").destination == "material" + + +def test_잔토는_터파기_빼기_되메우기() -> None: + result = expand(돌쌓기찰()) + assert 성분(result, "잔토처리").amount == pytest.approx( + 성분(result, "터파기").amount - 성분(result, "되메우기").amount + ) + + +# ── 전개식 — 실무 시트 값과 대조 ──────────────────────────────────── + + +def test_실무_시트_m당_값_재현() -> None: + """`기슭막이(찰쌓기, H=1.5, 기초무)` 시트 — 터파기 1.55 · 되메우기 0.30 · 잔토 1.25. + + ⚠ 우리 값은 1.5375 로 실무 1.55 와 0.012 차이가 난다. **우리 쪽이 맞다** — + 평균두께가 0.825 인데 실무 시트는 **표기값 0.83 으로 다시 계산**해서 1.545 가 됐다. + 품셈 1-2-2 의 소수 자리는 표기 규칙이고 계산은 전정밀로 둔다(PLAN 8-16). + 맞추려고 식을 비틀지 않는다 — 허용오차를 그 차이만큼 둔다. + """ + result = expand(돌쌓기찰(height=1.5, length=1.0)) + assert result.height_m == 1.5 + assert result.length_m == 1.0 + assert 성분(result, "터파기").amount == pytest.approx(1.55, abs=0.02) + assert 성분(result, "되메우기").amount == pytest.approx(0.30, abs=0.01) + assert 성분(result, "잔토처리").amount == pytest.approx(1.25, abs=0.02) + + +def test_평균두께는_전정밀로() -> None: + """실무가 0.83 으로 반올림해 재계산한 자리 — 우리는 0.825 를 그대로 쓴다.""" + result = expand(돌쌓기찰(height=1.5, length=1.0)) + # 터파기 = 높이 × (평균두께 + 0.2) × 연장 = 1.5 × 1.025 = 1.5375 + assert 성분(result, "터파기").amount == pytest.approx(1.5375) + + +def test_비탈면적은_기울기만큼_길어짐() -> None: + """1:0.3 이면 정면적 1.5 → 돌쌓기 1.566 (실무 표기 1.57). + + ⚠ 2026-09-08 ㉘ 정정 — 이 시험이 **기울기 몫을 두 번 곱하는 것**(× 1.566 × 1.04)을 + 계약으로 못 박고 있었다. 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 + **곧 기울기 몫**이다(1:0.3 → 1.0440). 값이 나오고 자원도 맞아 아무도 안 봤다. + """ + import math + + # ⚠ 2026-09-09 — 기울기가 **품셈 표준경사표로 자동 판정**되면서 찰 H=1.5 는 1:0.25 가 + # 되었다(확정 ⑨). 이 시험이 보는 것은 「기울기 몫을 한 번만 먹는가」이므로 + # **기울기를 못 박고** 본다. + components, _ = stone_masonry(1.5, 1.0, {"face_slope_ratio": 0.3}, wet=True) + masonry = next(c for c in components if c.name == "돌쌓기") + assert masonry.amount == pytest.approx(1.5 * math.hypot(1.0, 0.3)) + + +def test_돌쌓기_면적은_기울기_몫을_한_번만_먹는다_거울시험() -> None: + """⚠ 거울 시험 — `정면적 × hypot` 과 어긋나면 깨진다. 기울기를 바꿔도 따라와야 한다.""" + import math + + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import boulder_masonry + + for ratio in (0.3, 0.5, 1.0): + options = {"face_slope_ratio": ratio} + components, _ = stone_masonry(2.5, 10.0, options, wet=True) + 돌쌓기 = next(c for c in components if c.name == "돌쌓기") + assert 돌쌓기.amount == pytest.approx(25.0 * math.hypot(1.0, ratio)) + + options["stone_cm"] = "60~80" + components, _ = boulder_masonry(2.5, 10.0, options) + 큰돌 = next(c for c in components if c.name == "큰돌쌓기") + assert 큰돌.amount == pytest.approx(25.0 * math.hypot(1.0, ratio)) + + +def test_1대0점3_에서_실무_시트값과_맞는다() -> None: + """실무 돌골막이 시트 — 정면적 18.87 ㎡ → 돌쌓기 19.62 ㎡. 오차 0.5 % 안.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import STONE_MASONRY + + # ⚠ 실무 시트는 1:0.3 으로 그린 것이라 **그 기울기를 못 박고** 대조한다(2026-09-09). + # 자동 판정에 맡기면 직고 18.87m 로 읽혀 7m 초과 칸(1:0.45)이 되어 대조가 깨진다. + components, _ = stone_masonry(18.87, 1.0, {"face_slope_ratio": 0.3}, wet=True) + masonry = next(c for c in components if c.name == "돌쌓기") + assert masonry.amount == pytest.approx(19.62, rel=0.005) + # 시트가 반올림해 적은 1.04 가 곧 기울기 몫임을 함께 박는다. + assert STONE_MASONRY["sheet_check_factor_at_0_3"] == pytest.approx( + masonry.amount / 18.87, rel=0.005 + ) + + +def test_길이에_비례() -> None: + one = expand(돌쌓기찰(length=1.0)) + ten = expand(돌쌓기찰(length=10.0)) + assert 성분(ten, "터파기").amount == pytest.approx(성분(one, "터파기").amount * 10) + + +def test_메쌓기는_콘크리트_모르터가_없음() -> None: + result = expand({**돌쌓기찰(), "type_id": "masonry_dry"}) + names = [c.name for c in result.components] + assert "채움콘크리트" not in names + assert "모르터" not in names + assert "돌쌓기" in names + + +# ── 계수표 — 식에 박지 않는다 ─────────────────────────────────────── + + +def test_뒷길이를_바꾸면_계수가_따라감() -> None: + """실무 방식의 약점을 고친 자리 — 뒷길이가 바뀌어도 식을 안 고친다(PLAN 8-8 ㉮).""" + a = expand(돌쌓기찰(stone_back_length_cm=45)) + b = expand(돌쌓기찰(stone_back_length_cm=55)) + ratio = STONE_BACK_LENGTH_TABLE[55]["fill_concrete_m3_per_m2"] / ( + STONE_BACK_LENGTH_TABLE[45]["fill_concrete_m3_per_m2"] or 1 + ) + assert 성분(b, "채움콘크리트").amount == pytest.approx(성분(a, "채움콘크리트").amount * ratio) + + +def test_원본에_없는_칸은_지어내지_않음() -> None: + """뒷길이 60㎝ **야면석**의 돌중량은 관측표가 비어 있다 — 값을 만들지 않고 알린다. + + ⚠ 2026-09-09 확정 5차 큰 것 7 로 **계산식이 기본**이 되면서, 종류를 안 고른 자리는 + 이제 표가 비어도 계산식으로 선다. 관측표를 보는 것은 **야면석 계열뿐**이라 그 조건으로 + 옮긴다 — 「빈 칸을 안 지어낸다」는 못이 사라진 것이 아니라 **자리가 좁아진 것**이다. + """ + result = expand(돌쌓기찰(stone_back_length_cm=60, stone_kind="야면석·호박돌")) + assert not any(c.unit == "ton" for c in result.components) + assert any("돌중량" in note for note in result.notes) + + +# ── 모르는 종류·빈 값 ──────────────────────────────────────────────── + + +def test_전개식이_없는_종류는_물량을_내지_않음() -> None: + """옹벽은 이제 **관측 원단위표**로 간다(치수가 저장돼 있지 않아 식을 못 세움). + 형식(반중력식…)을 안 고르면 규격이 안 맞아 **미확보**로 드러난다 — 값을 지어내지 않는다.""" + result = expand({"type_id": "retaining_wall", "options": {"height_m": 2.0, "length_m": 5.0}}) + assert result.components == [] + assert any("자료에 없습니다" in note for note in result.notes) + + +def test_모르는_종류는_전개식도_원단위도_없음() -> None: + result = expand({"type_id": "듣도보도못한구조물", "options": {"height_m": 2.0}}) + assert result.components == [] + assert any("수량 산출식이 아직 없습니다" in note for note in result.notes) + + +def test_높이가_없으면_전개하지_않음() -> None: + result = expand(돌쌓기찰(height=0.0)) + assert result.components == [] + assert result.notes + + +def test_표_모양() -> None: + table = build_table([돌쌓기찰(length=10.0), {**돌쌓기찰(), "type_id": "masonry_dry"}]) + assert table["structure_count"] == 2 + assert table["totals"] + assert all({"name", "unit", "amount", "destination"} <= set(entry) for entry in table["totals"]) + + +def test_물구멍_잠정값이_근거에_적힐것() -> None: + """⚠ 「미확정」만 적으면 무엇을 정해야 하는지 모른다 — 지금 값과 법 범위를 함께 적는다.""" + # ⚠ 이름이 「물구멍」 → 「물구멍관」 으로 바뀜(2026-09-08) — 자재 카탈로그가 이름으로 + # 줄을 찾고, 그 규약이 「공백 없는 한 낱말」이다(B09 확인). + basis = 성분(expand(돌쌓기찰()), "물구멍관").basis + # ⚠ 문구가 「잠정」 → 「안 정함」으로 바뀜(2026-09-09) — **무엇을 안 정했는지**가 + # 낱말에 붙어 화면에서 바로 읽힌다. + assert "안 정함" in basis + assert "Ø50" in basis # 실무 관측값 + assert "2~3㎡" in basis # 법이 정한 범위 + + +def test_큰돌쌓기를_돌쌓기_식으로_돌리지_않을것() -> None: + """⚠ 2026-09-07 발견 — 큰돌쌓기는 품셈 **13-6**, 돌쌓기는 **13-4** 로 **규격 축이 다르다**. + 돌쌓기는 뒷길이(35·45·55·60㎝), 큰돌쌓기는 직경(40~60·60~80·80~100㎝). + 앞서 `stone_masonry(dry)` 로 전개해 직경 60~80㎝ 짜리가 「뒷길이 45㎝」 계수로 돌고 있었다. + **값이 나오기는 해서 어떤 시험도 안 잡던 자리** — 「값이 있기는 하니 안 보이는」 그것이다. + + ⚠ 지금은 13-6 축 전개식이 섰다. 그래서 **뒷길이 계수에서 나오던 성분이 없는지**로 잰다 — + 「전개가 안 된다」가 아니라 「**틀린 축으로 안 돈다**」가 지켜야 할 것이다. + """ + result = expand( + { + "type_id": "boulder_masonry", + "start_m": 10.0, + "end_m": 20.0, + "options": {"height_m": 2.0, "length_m": 10.0, "stone_cm": "60~80"}, + } + ) + bases = " ".join(component.basis for component in result.components) + assert "뒷길이" not in bases # 13-4 계수표가 안 걸렸다 + assert "13-6" in bases + names = {component.name for component in result.components} + # ⚠ 버림 콘크리트가 2026-09-09 확정 ⑭ 로 들어왔다 — 기초 바닥에 따로 치는 줄이다. + assert names == {"큰돌쌓기", "버림콘크리트", "터파기", "되메우기", "잔토처리"} + + +# ── 큰돌쌓기(품셈 13-6) — 규격 축이 직경이다 (2026-09-07 ⑲) ──────── + + +def 큰돌(diameter: str = "60~80", height: float = 2.0, length: float = 10.0) -> dict: + return { + "structure_id": "b1", + "type_id": "boulder_masonry", + "start_m": 10.0, + "end_m": 10.0 + length, + "options": {"height_m": height, "length_m": length, "stone_cm": diameter}, + } + + +def test_큰돌쌓기가_직경_축으로_설것() -> None: + """⚠ 앞서 돌쌓기(13-4) 뒷길이 계수로 돌던 자리 — 이제 13-6 축으로 선다.""" + result = expand(큰돌()) + area = 성분(result, "큰돌쌓기") + assert area.unit == "㎡" + assert "직경 60~80㎝" in area.basis and "13-6" in area.basis + # 돌쌓기 계열 성분(뒷길이 계수로 나오던 것)은 안 나온다. + names = {c.name for c in result.components} + assert "고임돌" not in names and "야면석" not in names and "막자갈" not in names + + +def test_표에_없는_직경은_지어내지_않을것() -> None: + result = expand(큰돌(diameter="120~150")) + assert result.components == [] + assert any("돌 직경이 아직 입력되지 않았습니다" in note for note in result.notes) + + +def test_직경이_비어_있으면_전개하지_않음() -> None: + result = expand({"type_id": "boulder_masonry", "options": {"height_m": 2.0, "length_m": 5.0}}) + assert result.components == [] + + +def test_품에_포함된_것을_따로_세우지_않을것() -> None: + """13-6 [주]① 「고임돌 및 채움콘크리트 품은 포함되어 있다」 — 세우면 이중계상.""" + result = expand(큰돌()) + assert any("품에 포함" in note for note in result.notes) + + +def test_재료_원단위가_없음을_알릴것() -> None: + """13-6 [주]⑦ 「재료량은 설계수량을 적용한다」 — 뒷길이별 돌중량 표에 해당하는 것이 없다.""" + result = expand(큰돌()) + assert any("재료(큰돌) 원단위 미확보" in note for note in result.notes) + + +def test_메찰_구분이_없음을_알릴것() -> None: + result = expand(큰돌()) + assert any("메/찰" in note for note in result.notes) + + +def test_터파기는_직경_위끝을_벽두께로_볼것() -> None: + """품셈에 큰돌쌓기 터파기 폭 규정이 없어 돌쌓기 방식을 준용한다 — 근거에 그 사실을 적는다.""" + result = expand(큰돌(diameter="60~80", height=2.0, length=10.0)) + dig = 성분(result, "터파기") + assert dig.amount == pytest.approx(2.0 * (0.8 + 0.2) * 10.0) + assert "준용" in dig.basis + + +# ── 돌쌓기 뒷길이 — 저장 칸 이름이 달랐다 (2026-09-07 발견) ───────── + + +def test_저장_칸_이름으로_뒷길이를_읽을것() -> None: + """⚠ 레지스트리는 `back_len_cm` 인데 엔진이 `stone_back_length_cm` 을 읽고 있었다. + 저장값이 영영 안 닿아 **뒷길이를 75 로 골라도 45 계수**가 붙던 자리다.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _back_length + + assert _back_length({"back_len_cm": "55"}) == 55 + assert _back_length({"stone_back_length_cm": 35}) == 35 # 옛 이름도 본다 + assert _back_length({}) == 45 # 없으면 기본 + + +def test_뒷길이를_접지_않는다() -> None: + """⚠ 2026-09-08 정정 — 앞서 「덮는 위 칸으로 접는다」를 계약으로 못 박고 있었다. + + 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 다 주므로 접을 + 까닭이 없었다. 접으면 **40㎝ 가 45㎝ 계수로 조용히** 돌고 999㎝ 도 60 으로 접혔다. + """ + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _back_length + + assert _back_length({"back_len_cm": "25"}) == 25 + assert _back_length({"back_len_cm": "75"}) == 75 + assert _back_length({"back_len_cm": "40"}) == 40, "표에 없는 값도 그대로 — 접지 않는다" + + +def test_표에_없는_뒷길이는_물량을_안_낸다() -> None: + """⚠ 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다.""" + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import stone_masonry + + components, notes = stone_masonry( + 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 40}, wet=True + ) + assert components == [] + assert "품셈 표(25·30·35·45·55·60·75㎝)에 없어" in notes[0] + + +def test_큰돌쌓기_메찰을_고르면_그_사유가_사라진다() -> None: + """⚠ 사유가 거짓이면 **사유 칸 전체를 못 믿게 된다**(2026-09-09). + + `bond` 는 레지스트리에 있는 칸인데 조건 없이 「못 고름」을 붙이고 있었다. + ⚠ 얻는 것은 노무 품 갈래뿐 — 재료 원단위는 13-6 [주]⑦ 때문에 여전히 안 선다. + """ + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import boulder_masonry + + base = {"height_m": 2.5, "length_m": 10.0, "stone_cm": "60~80"} + _, without = boulder_masonry(2.5, 10.0, dict(base)) + _, chosen = boulder_masonry(2.5, 10.0, {**base, "bond": "메쌓기"}) + assert any("메/찰" in note for note in without) + assert not any("메/찰" in note for note in chosen) + # 재료 미확보 사유는 **그대로 남아야 한다** — 그건 아직 참이다. + assert any("재료(큰돌) 원단위 미확보" in note for note in chosen) diff --git a/resources/tester/test_b08_wall_thickness.py b/resources/tester/test_b08_wall_thickness.py new file mode 100644 index 00000000..85639455 --- /dev/null +++ b/resources/tester/test_b08_wall_thickness.py @@ -0,0 +1,128 @@ +"""돌쌓기 벽 두께 — 실무 구조물도 식 (2026-09-08 사용자 확정 2차 ②). + +⚠ 겨누는 것 여섯 + ① **뒷길이가 두께를 움직인다** — 옛 식(0.45+0.10H / 0.45+0.40H)에는 뒷길이가 없어 + 35 로 바꿔도 같은 값이 섰다 + ② 상부 = 뒷길이 + 0.30 + ③ 하부 = 상부 + 0.30 × (H − 1.0) + ④ ⚠ H ≤ 1.0 에서 하부가 상부보다 얇아지지 않는다(음수 몫을 0 으로 눅임) + ⑤ 두께가 **돌 입적·터파기의 밑수**다 — 둘이 같이 움직인다 + ⚠ 2026-09-09 랩탑 보조가 확정 5차로 **막자갈을 이 축에서 떼어 냈다** — 뒷채움 폭은 + 정본 여섯 탭 공통값(상 0.30 · 하 0.45)이지 벽 두께가 아니었다. 시험이 겨누던 + 자리를 **입적**으로 옮긴다(같은 식을 근거에 싣는 줄). + ⑥ 근거 문구에 그 식이 적힌다 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import stone_masonry # noqa: E402 + + +def 성분(height_m: float, back_cm: int) -> dict: + components, _ = stone_masonry( + height_m, + 10.0, + {"height_m": height_m, "length_m": 10.0, "back_len_cm": back_cm}, + wet=True, + face="성토", + ) + return {c.name: c for c in components} + + +def 평균두께(height_m: float, back_cm: int) -> float: + top = back_cm / 100.0 + 0.30 + bottom = top + 0.30 * max(height_m - 1.0, 0.0) + return (top + bottom) / 2.0 + + +def test_뒷길이가_두께를_움직인다() -> None: + """⚠ 옛 식에는 뒷길이가 없었다 — 45 든 35 든 터파기가 같은 값으로 섰다.""" + 굵은 = 성분(2.0, 45)["터파기"].amount + 가는 = 성분(2.0, 35)["터파기"].amount + assert 굵은 > 가는 + + +def test_상부는_뒷길이에_0_30을_더한_값() -> None: + basis = 성분(2.5, 45)["입적"].basis + assert "상부 0.75" in basis # 0.45 + 0.30 + + +def test_하부는_높이_1m를_넘는_만큼_두꺼워진다() -> None: + basis = 성분(2.5, 45)["입적"].basis + assert "하부 1.20" in basis # 0.75 + 0.30 × (2.5 − 1.0) + + +def test_1m_이하에서는_상하부가_같다() -> None: + """⚠ (H−1) 을 그대로 곱하면 낮은 벽에서 하부가 상부보다 **얇아진다**.""" + basis = 성분(0.8, 35)["입적"].basis + assert "상부 0.65" in basis and "하부 0.65" in basis + + +def test_터파기가_평균두께에_매인다() -> None: + got = 성분(2.5, 45)["터파기"].amount + assert abs(got - 2.5 * (평균두께(2.5, 45) + 0.2) * 10.0) < 1e-6 + + +def test_뒷길이가_커져도_막자갈은_안_는다() -> None: + """⚠ 2026-09-09 확정 5차로 막자갈이 **뒷채움 정본 폭**(상 0.30 · 하 0.45)으로 갈렸다. + + 옛 식에서는 「입적 − 몸통」이라 뒷길이를 따라 아주 조금 움직였는데(12.653 → 12.630 → + 12.607), 지금은 **뒷길이를 아예 안 본다** — 값이 셋 다 같다. 두께가 실제로 움직이는 + 것은 **입적·터파기·잔토**에서 드러난다. + """ + 막자갈 = [성분(2.5, b)["막자갈"].amount for b in (35, 45, 55)] + assert len(set(막자갈)) == 1 + 터파기 = [성분(2.5, b)["터파기"].amount for b in (35, 45, 55)] + assert 터파기 == sorted(터파기) and 터파기[2] - 터파기[0] > 4.0 + + +def test_근거에_식이_적힌다() -> None: + """⚠ 값만 바뀌고 근거가 그대로면 왜 달라졌는지 화면에서 못 짚는다.""" + for name in ("입적", "터파기"): + basis = 성분(2.5, 45)[name].basis + assert "실무 구조물도 식" in basis + + +def test_입적이_줄로_선다() -> None: + """정본 계산표 좌측 열 이름 그대로다 — 「체적」으로 바꾸지 않는다 (확정 ⑦).""" + got = 성분(2.5, 45)["입적"] + assert got.unit == "㎥" + assert abs(got.amount - 2.5 * 10.0 * 평균두께(2.5, 45)) < 1e-9 + + +def test_석적은_정본에_없다는_사유를_달고_선다() -> None: + """⚠ 뒤집힌 시험이다 — 종전에는 「안 낸다」였다. + + 2026-09-09 확정 5차 작은 것 4 로 **세우기로 정했다.** 다만 정본 여섯 탭에는 없고 + 소광리 시트에만 있는 줄이라 **그 사실이 근거에 적혀야** 한다(값만 서고 출처가 없으면 + 나중에 어디서 왔는지 못 짚는다). + """ + got = 성분(2.5, 45)["석적"] + assert got.unit == "㎥" + assert "정본에 없는 줄" in got.basis and "소광리" in got.basis + + +def test_입적은_자재총괄에_안_섞인다() -> None: + """⚠ 보여 주기만 하는 줄이다 — 자재로 서면 돌을 두 번 센다.""" + from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as 자재 + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as 원단위 + + unit = 원단위( + [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, + } + ] + ) + assert 성분(2.5, 45)["입적"].destination == "reference" + assert all(row["name"] != "입적" for row in 자재(unit)["rows"]) diff --git a/resources/tester/test_b08_wall_trench.py b/resources/tester/test_b08_wall_trench.py new file mode 100644 index 00000000..1b019897 --- /dev/null +++ b/resources/tester/test_b08_wall_trench.py @@ -0,0 +1,83 @@ +"""벽 터파기 — 공용 단면 함수 한 벌을 쓴다 (2026-09-08). + +⚠ 겨누는 것 여섯 + ① 식을 여기서 다시 짜지 않는다 — `common_util_excavation.wall_trench_area_m2` 값 그대로 + ② 「기초유」면 기초분 0.45㎥/m 가 붙는다 + ③ 「기초버림」이면 0.07㎥/m 로 준다 — 기초를 **안 두는 것이 아니라 얕게 두는 것** + ④ ⚠ 안 고른 프로젝트는 **비탈분만** 서고 사유가 뜬다 — 한쪽으로 찍으면 0.45 가 임의로 굳는다 + ⑤ 되메우기 = (기초깊이 + H) × 0.2 + ⑥ 큰돌쌓기도 같은 함수를 탄다 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from common_util.common_util_excavation import wall_trench_area_m2 # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + boulder_masonry, + stone_masonry, +) + +H = 2.5 +L = 10.0 +BACK_CM = 45 +두께 = ((BACK_CM / 100 + 0.30) + (BACK_CM / 100 + 0.30 + 0.30 * (H - 1.0))) / 2 # 0.975 + + +def 성분(**options: object) -> tuple[dict, list[str]]: + components, notes = stone_masonry( + H, L, {"height_m": H, "length_m": L, "back_len_cm": BACK_CM, **options}, wet=True + ) + return {c.name: c for c in components}, notes + + +def test_공용_함수_값을_그대로_쓴다() -> None: + """⚠ 화면(B06 횡단도)이 그 함수로 그린다 — 여기서 다시 짜면 그림과 물량이 갈린다.""" + got, _ = 성분(foundation="기초유") + expected = wall_trench_area_m2(H, 두께, has_foundation=True) * L + assert abs(got["터파기"].amount - expected) < 1e-9 + + +def test_기초유는_기초분_0_45가_붙는다() -> None: + 유, _ = 성분(foundation="기초유") + 미지정, _ = 성분() + assert abs((유["터파기"].amount - 미지정["터파기"].amount) - 0.45 * L) < 1e-9 + + +def test_기초버림은_0_07로_준다() -> None: + """⚠ 「기초무」가 아니라 「기초버림」이다 — 얕게 두는 것이라 몫이 남는다.""" + 버림, _ = 성분(foundation="기초버림") + 미지정, _ = 성분() + assert abs((버림["터파기"].amount - 미지정["터파기"].amount) - 0.07 * L) < 1e-9 + + +def test_안_고르면_비탈분만_서고_사유가_뜬다() -> None: + got, notes = 성분() + assert abs(got["터파기"].amount - H * (두께 + 0.2) * L) < 1e-9 + assert any("기초 유/무" in n for n in notes) + + +def test_되메우기는_기초깊이를_함께_센다() -> None: + 유, _ = 성분(foundation="기초유") + assert abs(유["되메우기"].amount - (0.5 + H) * 0.2 * L) < 1e-9 + 미지정, _ = 성분() + assert abs(미지정["되메우기"].amount - H * 0.2 * L) < 1e-9 + + +def test_잔토는_터파기_빼기_되메우기() -> None: + got, _ = 성분(foundation="기초유") + assert abs(got["잔토처리"].amount - (got["터파기"].amount - got["되메우기"].amount)) < 1e-9 + + +def test_큰돌쌓기도_같은_함수를_탄다() -> None: + components, _ = boulder_masonry( + H, L, {"height_m": H, "length_m": L, "stone_cm": "60~80", "foundation": "기초유"} + ) + got = {c.name: c for c in components} + expected = wall_trench_area_m2(H, 0.80, has_foundation=True) * L + assert abs(got["터파기"].amount - expected) < 1e-9 diff --git a/resources/tester/test_b08_wood_chipping.py b/resources/tester/test_b08_wood_chipping.py new file mode 100644 index 00000000..46689078 --- /dev/null +++ b/resources/tester/test_b08_wood_chipping.py @@ -0,0 +1,60 @@ +"""임목파쇄 — 켤 수 있는 칸 (2026-09-09 사용자 확정 5차 5번). + +「근주이식·임목파쇄는 **기본 안 셈**」이되 「⚠ 현장에 따라 **파쇄가 적용될 필요 있음**」이라 +**임목파쇄만** 켤 수 있게 둔다. **근주이식은 칸도 안 만든다.** + +⚠ 겨누는 것 다섯 + ① 기본은 **줄이 아예 없다** — 빈 칸이 「세야 함」으로 읽히면 안 된다 + (부대시설과 다른 자리 — 그쪽은 **법정 의무**라 줄이 늘 선다) + ② 켜면 줄이 선다 + ③ ⚠ 켜도 **부피는 지어내지 않는다** — 넣어야 값이 선다 + ④ 부피를 넣으면 값이 서고 코드가 붙는다(FP-08-11 이동식 임목 파쇄) + ⑤ 근주이식은 **어느 경우에도 줄이 없다** +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 + STATUS_PENDING, + STATUS_READY, + build_table, +) + + +def 표(enabled: object = False, volume: object = None) -> dict: + table = build_table({}, [], [], None, None, {}, None, None, enabled, volume) + return {row["item"]: row for row in table["rows"]} + + +def test_기본은_줄이_없다() -> None: + """⚠ 켤 자리가 없으면 물을 일도 없다 — 안 켠 프로젝트에 빈 줄을 세우지 않는다.""" + assert "임목파쇄" not in 표() + + +def test_켜면_줄이_선다() -> None: + assert "임목파쇄" in 표(True) + + +def test_켜도_부피는_지어내지_않는다() -> None: + row = 표(True)["임목파쇄"] + assert row["amount"] is None and row["status"] == STATUS_PENDING + assert "부피" in row["reason"] + + +def test_부피를_넣으면_값이_선다() -> None: + row = 표(True, 78.0)["임목파쇄"] + assert row["amount"] == 78.0 and row["status"] == STATUS_READY + assert row["work_item_code"] == "FP-08-11" + assert row["unit"] == "㎥" + + +def test_근주이식은_어느_경우에도_없다() -> None: + """확정 5차 5번 — 근주이식(FP-14-02)은 **칸도 안 만든다**.""" + for enabled, volume in ((False, None), (True, None), (True, 78.0)): + assert "근주이식" not in 표(enabled, volume) diff --git a/resources/tester/test_b08_wording.py b/resources/tester/test_b08_wording.py new file mode 100644 index 00000000..d2a5fa20 --- /dev/null +++ b/resources/tester/test_b08_wording.py @@ -0,0 +1,107 @@ +"""사용자에게 뜨는 문구 검사 — **키 이름이 화면에 새지 않는다** (B08 ㉑). + +⚠ 「`back_len_cm` 가 저장돼 있지 않아 갈래를 못 고름」처럼 개발자 키가 그대로 뜨던 자리가 + 있었다. 사용자는 그 이름을 모르고 무엇을 해야 하는지도 알 수 없다. +⚠ 「없다」만 말하지 않는다 — **어디서 채우면 풀리는지**까지 있어야 그 말이 쓸모 있다. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, masonry_class # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table, expand # noqa: E402 +from B08_Quantity.B08_Quantity_Wording import ( # noqa: E402 + OPTION_LABELS, + option_missing, + spec_missing, + type_label, +) + +#: 화면 문구에 나오면 안 되는 것 — 저장 제원 키·구조물 `type_id` 같은 개발자 이름. +LEAK_PATTERN = re.compile( + r"back_len_cm|stone_cm|height_m|length_m|face_slope_ratio" + r"|masonry_wet|masonry_dry|boulder_masonry|retaining_wall|soil_guard|pipe_inlet_basin" +) + + +def _notes(structures: list[dict]) -> list[str]: + table = build_table(structures) + return [note for item in table["structures"] for note in item["notes"]] + + +def test_돌쌓기_뒷길이_안내가_사람_말일것() -> None: + kind, why = masonry_class({}) + assert kind is None + assert "뒷길이" in why + assert not LEAK_PATTERN.search(why), why + # 어디서 채우면 되는지가 있어야 한다. + assert "입력" in why + + +def test_큰돌쌓기_직경_안내가_사람_말일것() -> None: + notes = _notes([{"type_id": "boulder_masonry", "options": {"height_m": 2.0, "length_m": 10.0}}]) + assert notes and "돌 직경" in notes[0] + assert not LEAK_PATTERN.search(notes[0]), notes[0] + + +def test_산출식_없는_종류_안내가_사람_말일것() -> None: + notes = _notes([{"type_id": "soil_guard", "options": {"height_m": 2.0, "length_m": 10.0}}]) + assert any("흙막이" in note for note in notes) + assert not any(LEAK_PATTERN.search(note) for note in notes), notes + + +def test_규격_미확보_안내가_사람_말일것() -> None: + result = expand( + { + "type_id": "retaining_wall", + "options": {"height_m": 1.6, "length_m": 10.0, "form": "반중력식"}, + } + ) + note = next(n for n in result.notes if "규격" in n) + assert "옹벽" in note and "옹벽 형식" in note + assert not LEAK_PATTERN.search(note), note + + +def test_인계_못이은_줄도_사람_말일것() -> None: + unit = build_table( + [ + { + "type_id": "soil_guard", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.0, "length_m": 10.0}, + } + ] + ) + unmatched = build_handoff(unit_quantity_table=unit)["unmatched_work_items"] + assert unmatched and "흙막이" in unmatched[0] + assert not any(LEAK_PATTERN.search(item) for item in unmatched), unmatched + + +def test_모르는_키는_지어내지_않을것() -> None: + """⚠ 잘못된 안내가 없는 안내보다 나쁘다 — 모르면 키를 그대로 보인다.""" + text = option_missing("듣도보도못한키") + assert "듣도보도못한키" in text + + +def test_라벨표에_어디서_채우는지가_있을것() -> None: + for key, (label, where) in OPTION_LABELS.items(): + assert label and label != key, key + assert isinstance(where, str) + + +def test_이름표에_없으면_원래_값을_보일것() -> None: + assert type_label("듣도보도못한종류") == "듣도보도못한종류" + assert type_label("masonry_wet") == "돌쌓기(찰)" + # 레지스트리 이름이 있으면 그것이 먼저다. + assert type_label("masonry_wet", {"masonry_wet": "돌쌓기(찰쌓기)"}) == "돌쌓기(찰쌓기)" + + +def test_자료가_통째로_없으면_그렇게_말할것() -> None: + assert "표준 물량 자료" in spec_missing("box_culvert", []) diff --git a/resources/tester/test_b08_work_item_master.py b/resources/tester/test_b08_work_item_master.py new file mode 100644 index 00000000..0065796f --- /dev/null +++ b/resources/tester/test_b08_work_item_master.py @@ -0,0 +1,752 @@ +"""B08 공종 마스터 정규화 검사 — PLAN 8-6·8-7. + +제일 중요한 것은 `pum_form` 이다. 생산량형(1÷값)과 소요량형(값÷밑수)은 환산 방향이 +반대라, 뒤집히면 값이 오류 없이 조용히 20배쯤 틀린다. 그래서 실물 표를 집어 +방향이 맞는지 못 박아 둔다. +""" + +from __future__ import annotations + +import json +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_Build_WorkItemMaster import ( # noqa: E402 + OUT_DIR, + detect_form, + parse_toc, + section_number, +) + +MASTER = OUT_DIR / "work_item_master_2026-01-01.json" +UNDET = OUT_DIR / "form_undetermined_2026-01-01.json" + + +@pytest.fixture(scope="module") +def master() -> dict: + if not MASTER.exists(): + pytest.skip("공종 마스터 미생성 — 빌드 스크립트를 먼저 실행할 것") + return json.loads(MASTER.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def tables() -> list[dict]: + src = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json" + return json.loads(src.read_text(encoding="utf-8"))["variables"]["pum"]["tables"] + + +def find(tables: list[dict], table_id: str) -> dict: + return next(t for t in tables if t["table_id"] == table_id) + + +# ── pum_form — 뒤집히면 값이 반대가 되는 자리 ────────────────────────── + + +def test_생산량형_작업능력표(tables: list[dict]) -> None: + """`9-4-1 암파쇄` 는 「㎥/hr」 작업능력표다 — 품 = 1 ÷ 값.""" + form, why = detect_form(find(tables, "F0241"), "9") + assert form == "productivity", why + + +def test_생산량형이_비고의_직종에_뒤집히지_않을것(tables: list[dict]) -> None: + """`9-7-1` 은 「작업능력」 표인데 비고에 「보통인부 1인/일」이 붙어 있다. + + 직종을 먼저 보면 소요량형으로 뒤집힌다 — 그 사고를 막는 순서를 고정한다. + """ + table = find(tables, "F0247") + assert "보통인부" in json.dumps(table["rows"], ensure_ascii=False) + form, why = detect_form(table, "9") + assert form == "productivity", why + + +def test_소요량형_직종표(tables: list[dict]) -> None: + """`9-3-1 인력` 은 「보통인부(인) 0.16」 — ㎥당 품이다.""" + form, why = detect_form(find(tables, "F0239"), "9") + assert form == "requirement", why + + +def test_소요량형_직종이_둘째열에_있어도_잡을것(tables: list[dict]) -> None: + """`9-12-1 토사` 는 첫 열이 「인력(10%)」, 직종은 둘째 열에 있다.""" + form, why = detect_form(find(tables, "F0258"), "9") + assert form == "requirement", why + + +def test_계수표는_공종이_아님(tables: list[dict]) -> None: + """`9-3-2 기계` 는 시공능력 공식의 K·f·E 파라미터다.""" + form, why = detect_form(find(tables, "F0240"), "9") + assert form == "coefficient", why + + +def test_1장은_기준표(tables: list[dict]) -> None: + """품셈 제1장은 적용기준이라 공종으로 세우지 않는다.""" + form, why = detect_form(find(tables, "F0008"), "1") + assert form == "reference", why + + +def test_참조지시는_기준표(tables: list[dict]) -> None: + """`10-3-1 모래` 는 값이 아니라 「별도계상」 이라는 지시뿐이다.""" + form, why = detect_form(find(tables, "F0298"), "10") + assert form == "reference", why + + +# ── 계층·코드 ──────────────────────────────────────────────────────── + + +def test_절번호_파싱() -> None: + assert section_number("9-3-1. 인력") == "9-3-1" + assert section_number("12-2 표면 마무리를 따른다.") == "12-2" + assert section_number("부록1") is None + + +def test_목차_계층() -> None: + rows = [["제9장", "토공", "50"], ["9-3", "흙깎기", "51"], ["9-3-1", "인력", "51"]] + nodes = parse_toc(rows) + assert [n["work_item_code"] for n in nodes] == ["FP-09", "FP-09-03", "FP-09-03-01"] + assert [n["level"] for n in nodes] == [1, 2, 3] + assert nodes[2]["parent_code"] == "FP-09-03" + # STmate 관례대로 256 간격 — 중간 삽입 여유를 둔다. + assert [n["sort_order"] for n in nodes] == [256, 512, 768] + + +# ── 산출물 계약 (B09 가 읽는 부분) ───────────────────────────────────── + + +def test_원문셀을_버리지_않을것(master: dict) -> None: + """`raw_row` 가 없으면 B09 가 476표를 다시 열어야 한다 — PLAN 8-7.""" + got = [t for n in master["work_items"] for t in n["tables"]] + assert got, "표가 하나도 안 붙었다" + assert all("raw_row" in t for t in got) + assert any(t["raw_row"] for t in got) + + +def test_자원축은_비워둘것(master: dict) -> None: + """자원 축(`resource_kind` 등)은 B09 몫이다 — 여기서 채우면 경계 위반.""" + forbidden = {"resource_kind", "resource_code", "resource_spec", "amount", "amount_unit"} + for node in master["work_items"]: + for table in node["tables"]: + assert not (forbidden & set(table)), f"{table['pum_table_id']} 에 자원 축이 섞였다" + + +def test_dataset_version_세쪽_기록(master: dict) -> None: + """파일명만으로는 같은 날짜 재생성본과 구분이 안 된다 — PLAN 8-6.""" + dv = master["dataset_version"] + assert dv["dataset_id"] and dv["effective_date"] + assert len(dv["sha256"]) == 64 + + +def test_미판정은_빈칸이_아니라_목록으로(master: dict) -> None: + """판정 못 한 표를 조용히 비워 두면 뒤집힌 값이 들어간다 — 반드시 목록으로 낸다.""" + if not UNDET.exists(): + pytest.skip("미판정 목록 미생성") + listed = {i["pum_table_id"] for i in json.loads(UNDET.read_text(encoding="utf-8"))["items"]} + flagged = { + t["pum_table_id"] + for n in master["work_items"] + for t in n["tables"] + if t["pum_form"] == "undetermined" + } + assert flagged <= listed, "마스터에서 미판정인데 목록에 없는 표가 있다" + + +def test_귀속률(master: dict) -> None: + """표가 목차 계층에 붙어야 공종으로 선다. 미귀속이 늘면 목차 파싱이 깨진 것.""" + s = master["stats"] + assert s["tables_attached"] / s["tables_total"] > 0.9 + + +# ── 첫 칸이 분류 딱지인 표 (2026-09-07 교차 확인에서 나옴) ────────── +# +# 서브 창이 자기 파싱에서 「첫 칸이 갈래 딱지이고 이름이 둘째 칸」인 표를 놓쳐 자재·장비가 +# 통째로 빠지던 것을 잡았고, 같은 병이 여기 형태 판정에도 있었다 — 12장 임도 구조물 표 +# 8건이 `undetermined` 로 빠져 있었다. 다시 걸리지 않게 못 박는다. + + +def test_딱지형_표가_소요량형으로_판정될것() -> None: + """`['자재', 'PVC 지수판', 'm', '1.04']` — 첫 칸이 이름이 아니라 갈래다.""" + table = { + "headers": ["구 분", "단위", "적 용", "비 고", ""], + "rows": [ + ["자재", "PVC 지수판(200×5)", "m", "1.04", "4% 할증"], + ["용접봉", "kg", "0.042", "", ""], + ["철선(#8)", "kg", "0.21", "", ""], + ["인력(설치비)", "특별인부", "인", "0.151", ""], + ], + } + form, basis = detect_form(table, "12") + assert form == "requirement" + assert "딱지" in basis + + +def test_직종이_넷째_행에_있어도_잡힐것() -> None: + """첫 세 행만 보던 탓에 놓쳤던 모양 — 강관동바리의 형틀목공은 넷째 행이다.""" + table = { + "headers": ["구 분", "단위", "적 용", "비 고", "", ""], + "rows": [ + ["자재", "강관 동바리", "내관(48.6mm×2.4mm)", "본", "0.38", ""], + ["외관(60.6mm×2.3mm)", "본", "0.38", "", "", ""], + ["잡재료비(재료비의)", "%", "5", "", "", ""], + ["인력", "형틀목공", "인", "0.07", "", ""], + ], + } + assert detect_form(table, "12")[0] == "requirement" + + +def test_비율_지시_한_줄이_소요량표를_참조로_넘기지_않을것() -> None: + """⚠ 만들다 실제로 걸린 자리 — `잡재료비(재료비의) 5 %` 한 줄 때문에 강관동바리가 + 참조로 넘어갔다. **소요량을 먼저 보고** 비율 지시는 그 뒤에 본다.""" + ratio_only = { + "headers": ["구 분", "적 용", "비 고"], + "rows": [["재료비", "", ""], ["설치비", "재료비의 5%", ""]], + } + assert detect_form(ratio_only, "12")[0] == "reference" + + +def test_수치가_없으면_지어내지_않고_미판정으로_둘것() -> None: + """`['재료비', 'JOINT FILLER', '']` — 이름만 있고 소요량이 없다.""" + table = {"headers": ["구 분", "적 용", "비 고"], "rows": [["재료비", "JOINT FILLER", ""]]} + assert detect_form(table, "12")[0] == "undetermined" + + +def test_생산량형이_딱지_규칙에_안_밀릴것() -> None: + """딱지 규칙은 **맨 마지막**에 본다 — 앞의 판정을 흔들면 안 된다.""" + table = { + "headers": ["작업능력(㎥/hr)", "비고"], + "rows": [["자재", "보통인부 1인/일", "12.5"]], + } + assert detect_form(table, "9")[0] == "productivity" + + +# ── 딱지가 비율을 달고 오는 표 (2026-09-07 서브 창 제보로 확인) ───── +# +# `인력(10%)` · `장비(90%)` — 소요량형이면서 **장비 몫은 시공능력 공식**이라 값이 아니다. +# 형태 한 낱말로만 적으면 받는 쪽이 그 단가를 전량에 곱해 **내역서가 9할 싸게** 선다. + + +def test_몫이_적힌_표는_몫을_실을것() -> None: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import resource_shares + + table = { + "headers": ["구 분", "적 용", "비 고"], + "rows": [ + ["인력(10%)", "보통인부(인)", "0.23"], + ["장비 (90%)", "유압식백호우 (무한궤도,0.7㎥)", "k", "0.9"], + ], + } + assert resource_shares(table) == {"인력": 10.0, "장비": 90.0} + + +def test_몫이_없는_표는_빈칸() -> None: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import resource_shares + + assert resource_shares({"rows": [["보통인부(인)", "0.16"]]}) == {} + + +def test_몫이_있으면_부분값으로_표시될것() -> None: + """⚠ 공식 기호가 **첫 표에만** 있고 이어지는 표는 물려받는다 — 기호가 있는 표만 세면 + 9-13-2 같은 절반을 놓친다. 넓게 잡되 **값을 지우지 않고 표시만** 한다.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import ( + capacity_formula_pending, + resource_shares, + ) + + inherited = { + "headers": ["구 분", "적 용", "비 고"], + "rows": [ + ["인력 (10%)", "보통인부(인)", "0.26"], + ["장비 (90%)", "유압식백호우 (무한궤도,0.7㎥)"], + ], + } + assert resource_shares(inherited) # 몫은 있고 + assert not capacity_formula_pending(inherited) # 공식 기호는 이 표에 없다 + + +def test_형태_판정은_그대로_소요량형() -> None: + """몫 표시를 더해도 앞의 판정을 흔들지 않는다.""" + table = { + "headers": ["구 분", "적 용", "비 고"], + "rows": [["인력(10%)", "보통인부(인)", "0.23"], ["장비(90%)", "유압식백호우"]], + } + assert detect_form(table, "9")[0] == "requirement" + + +# ── 밑수는 표 안이 아니라 **표 바로 위 본문**에 있다 (2026-09-07) ──── +# +# 서브 창이 「기준 단위 없음으로 도는 단가가 125 중 122」를 보고했고 파 보니 뿌리가 여기였다. +# 「10㎡당」 표를 1㎡당으로 알면 **곱셈이 10배 틀린다.** +# ⚠ 앞서 확인한 「밑수가 **밀렸나**」와는 다른 물음이다 — 이번은 「**아예 안 적혔나**」다. + + +def _basis(text_lines: list[str]) -> tuple[float | None, str | None]: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source + + # 마지막 줄이 표라고 보고, 그 줄 번호(1-기반)를 넘긴다. + return basis_from_source(text_lines, len(text_lines)) + + +def test_본문에서_밑수를_읽을것() -> None: + assert _basis(["### 12-2. 표면 마무리", "", "(단위: ㎡당)", "", "| 구 분 |"]) == (1.0, "㎡") + assert _basis(["### 5-1-4. 떼채취", "", "(100㎡당)", "", "| 구 분 |"]) == (100.0, "㎡") + + +def test_분모가_밑수인_꼴() -> None: + """`(단위: 인/㎡당)` — 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다.""" + assert _basis(["### 13-4-1. 메쌓기(인력)", "", "(단위: 인/㎡당)", "", "| 뒷길이 |"]) == ( + 1.0, + "㎡", + ) + + +def test_당도_단위도_없으면_밑수가_아님() -> None: + """⚠ 만들다 실제로 걸린 자리 — `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다.""" + assert _basis(["| 장비 | 유압식백호우 (무한궤도,0.7㎥) |", "", "| 구 분 |"]) == (None, None) + + +def test_앞_표의_밑수를_물어오지_않을것() -> None: + """위로 거슬러 보되 다른 표에 닿으면 멈춘다 — 남의 밑수를 물어 오면 조용히 틀린다.""" + lines = ["(100㎡당)", "| 앞 표 |", "| 값 |", "", "### 9-21. 제근", "", "| 종 류 |"] + assert _basis(lines) == (None, None) + + +def test_묶음_기준_판정() -> None: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_quantity_is_grouped + + assert basis_quantity_is_grouped(10.0) and basis_quantity_is_grouped(100.0) + assert not basis_quantity_is_grouped(1.0) + assert not basis_quantity_is_grouped(None) + + +def test_산출물에_밑수_미확보_목록이_나올것() -> None: + """빈칸으로 두면 「1단위당」으로 오해된다 — 곱하면 안 되는 줄을 받는 쪽이 가릴 수 있게 낸다.""" + import json + + path = ROOT / "resources" / "data_work_item_master" / "basis_missing_2026-01-01.json" + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["items"] + # 곱할 값이 아닌 형태(참조·계수)는 목록에 안 담는다 — 잡음이 되면 안 본다. + assert {item["pum_form"] for item in payload["items"]} <= {"requirement", "productivity"} + + +def test_실물_묶음_기준이_실제로_실릴것() -> None: + """13-6-2 찰쌓기는 원문이 `(단위: 10㎡당)` 이다 — 1 로 두면 10배 틀린다.""" + import json + + path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" + master = json.loads(path.read_text(encoding="utf-8")) + tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} + assert tables["F0420"]["basis_quantity"] == 10.0 + assert tables["F0420"]["basis_unit"] == "㎡" + assert tables["F0420"]["basis_source"] == "본문" + + +# ── 값 자리에 식이 적힌 칸 (2026-09-07 서브 창 제보) ──────────────── +# +# `0.2 × 30%`(기초잡석 소할)처럼 계산이 그대로 적혀 있으면 **값이 숫자로 안 읽혀 +# 그 성분이 조용히 빠진다.** 형태 판정은 통과하므로 어떤 형태 검사에도 안 걸린다. +# ⚠ 식을 **계산하지 않는다** — 뜻을 잘못 읽으면 조용히 틀리므로 드러내기만 한다. + + +def test_식이_적힌_칸을_찾을것() -> None: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells + + table = { + "headers": ["구 분", "적 용", "비 고"], + "rows": [ + ["부설다짐", "할석공(인)", "0.6"], + ["소할(30%)", "할석공(인)", "0.2 × 30%"], + ], + } + assert expression_cells(table) == ["0.2 × 30%"] + + +def test_평범한_숫자는_식이_아님() -> None: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells + + assert expression_cells({"rows": [["보통인부(인)", "0.16"], ["계", "1,234.5"]]}) == [] + + +def test_긴_설명문은_식이_아님() -> None: + """⚠ 좁게 잡는다 — 비고의 설명문까지 걸면 목록이 잡음이 되어 결국 안 본다.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells + + rows = [["비고", "- 지주목을 세우지 않을 때는 인력품의 10%를 감한다"]] + assert expression_cells({"rows": rows}) == [] + + +def test_기초잡석이_실제로_잡힐것() -> None: + """서브가 실물에서 부딪힌 그 표 — 부설다짐 0.6인만 서고 소할 몫이 빠져 있었다.""" + import json + + path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" + master = json.loads(path.read_text(encoding="utf-8")) + tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} + assert tables["F0373"]["expression_cells"] == ["0.2 × 30%"] + # 배분율 딱지가 없어 `partial_ratio` 로는 안 걸리는 자리다 — 그래서 깃발이 따로 필요했다. + assert tables["F0373"]["partial_ratio"] is False + + +# ── 작업조 표 · 「단 위」 표지 제거 (2026-09-07 서브 창 제보) ──────── +# +# ⚠ 「형틀목공 4인 + 보통인부 1인 / 시공량 35㎡」의 **「4」는 소요량이 아니라 인원**이다. +# 행-자원으로 그냥 읽으면 **35배 부푼다**. 값을 바꾸지 않고 표시만 한다. + + +def test_작업조_표를_알아볼것() -> None: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import crew_table + + table = { + "headers": ["구 분", "단 위", "수 량", "시 공 량 (㎡)"], + "rows": [ + ["복 잡", "보 통", "간 단"], + ["형틀목공 보통인부", "인 인", "4 1", "25", "35", "40"], + ], + } + assert crew_table(table) is True + + +def test_평범한_소요량표는_작업조가_아님() -> None: + """⚠ 짝 시험 — 넓게 잡으면 멀쩡한 소요량표가 다 걸린다.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import crew_table + + assert ( + crew_table({"headers": ["구 분", "단 위", "수 량"], "rows": [["미 장 공", "인", "0.34"]]}) + is False + ) + + +def test_헤더_단위_만으로_참조가_되지_않을것() -> None: + """⚠ 이 시험이 이번 건의 핵심 — 「단위 열이 있다」는 소요량표의 흔한 모양이지 + 참조표의 표지가 아니다. 그 표지 하나로 **41건이 버려지고 있었고** 그 안에 + **돌쌓기(장비) 13-4-5**(우리 매핑이 실제로 쓰는 코드)가 있었다.""" + table = { + "headers": ["구 분", "단 위", "수 량"], + "rows": [["돌 쌓 기 공", "인", "0.32"], ["보통인부", "인", "0.28"]], + } + assert detect_form(table, "13")[0] == "requirement" + + +def test_돌쌓기_장비가_실제로_공종으로_설것() -> None: + """매핑이 `masonry_wet → FP-13-04-05` 로 쓰는 표다 — 참조로 버려지면 단가가 안 선다.""" + import json + + path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" + master = json.loads(path.read_text(encoding="utf-8")) + tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} + assert tables["F0414"]["pum_form"] == "requirement" # 13-4-5 찰쌓기(장비) + assert tables["F0407"]["pum_form"] == "requirement" # 13-4-2 메쌓기(장비) + + +def test_작업조_깃발이_산출물에_실릴것() -> None: + import json + + path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" + master = json.loads(path.read_text(encoding="utf-8")) + tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} + assert tables["F0395"]["crew_table"] is True # 유로폼 설치·해체 + assert tables["F0132"]["crew_table"] is True # 평떼 시비 — 서브가 실물에서 부딪힌 표 + assert tables["F0334"]["crew_table"] is False # 표면 마무리는 평범한 소요량표 + + +# ── 이름 표기 갈림 · 공식 기호 줄 (2026-09-07 서브 창 제보) ───────── +# +# ⚠ 같은 표 묶음 안에서도 표기가 갈린다 — `13-6-1` 은 「굴 삭 기 (무한궤도)」, +# 바로 옆 `13-6-2` 는 「굴착기 (무한궤도)」. 받는 쪽이 이름으로 자원을 찾으므로 +# 표기가 갈리면 **그 줄이 통째로 빠진다.** +# ⚠⚠ **여기서 이름을 고치지 않는다.** 정규화는 값을 살리지만 **잘못된 줄도 함께 살린다** — +# 서브 실례: 이름 정규화 직후 버킷계수 `K` 를 소요량으로 읽어 사용료가 이중이 됐다. + + +def test_자간_공백_자원_이름을_찾을것() -> None: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import spaced_names + + table = { + "rows": [["굴 삭 기 (무한궤도)", "0.8㎥", "h", "3.84"], ["작업반장", "", "인", "0.83"]] + } + assert spaced_names(table) == ["굴 삭 기 (무한궤도)"] + + +def test_머리글_재료명은_안_걸릴것() -> None: + """⚠ 짝 시험 — 「단 위」·「모 래」까지 걸면 101건이 되어 목록이 잡음이 된다. + **단위 칸과 수치가 함께 있는 자원 줄**만 본다.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import spaced_names + + assert spaced_names({"rows": [["단 위", "㎥"], ["모 래", "6"]]}) == [] + + +def test_공식_기호_줄을_찾을것() -> None: + """`K`·`f`·`E` 는 시공능력 공식 파라미터다 — 자원으로 세면 이중계상.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import formula_rows + + table = {"rows": [["k", "0.9"], ["f", "1/1.3"], ["보통인부", "인", "0.23"]]} + assert set(formula_rows(table)) == {"k", "f"} + + +def test_서브가_부딪힌_표가_실제로_잡힐것() -> None: + import json + + path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" + master = json.loads(path.read_text(encoding="utf-8")) + tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} + assert tables["F0419"]["spaced_names"] == ["굴 삭 기 (무한궤도)"] # 13-6-1 메쌓기 + assert tables["F0423"]["spaced_names"] == ["굴 삭 기 (무한궤도)"] # 13-7-2 찰붙이기 + # 바로 옆 13-6-2 찰쌓기는 공백이 없다 — 그래서 표기가 갈린다. + assert tables["F0420"]["spaced_names"] == [] + assert "k" in tables["F0258"]["formula_rows"] # 9-12-1 측구터파기 토사 + + +# ── 규격 표기 특수문자 (2026-09-07 갈래 계약) ────────────────────── +# +# ⚠ 원문이 한 종류로 안 쓴다 — 물결표만 셋(`∼` 662 · `~` 459 · `~` 2), 곱셈표 둘. +# **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다.** +# 그래서 인계는 키를 조립하지 않고 **저장 원본값만** 보낸다. 여기서는 표시만 한다. + + +def test_특수문자_종류를_찾을것() -> None: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import special_glyphs + + table = {"rows": [["직경 40㎝이상∼60㎝미만", "600 x 1,200mm"]]} + found = special_glyphs(table) + assert "∼(U+223C)" in found and "x(U+0078)" in found + + +def test_평범한_글자는_안_걸릴것() -> None: + """⚠ 짝 시험 — 넓게 잡으면 목록이 잡음이 된다.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import special_glyphs + + assert special_glyphs({"rows": [["보통인부", "인", "0.16"]]}) == [] + + +def test_물결표가_두_종류_섞인_표가_실제로_있을것() -> None: + """⚠ 이것이 계약을 바꾼 근거다 — 같은 절 안에서도 표기가 갈린다.""" + import json + + path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json" + master = json.loads(path.read_text(encoding="utf-8")) + tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])} + mixed = [ + tid + for tid, t in tables.items() + if sum(1 for g in t.get("special_glyphs") or [] if "U+223C" in g or "U+FF5E" in g) >= 2 + ] + assert mixed, "물결표가 섞인 표를 못 찾았다 — 계약 근거가 사라졌는지 확인할 것" + + +# ── 참조 표지 — 41건을 지웠던 그 규칙의 짝 시험 (2026-09-08 ㉘) ──────────────── +# 넓히면 정상 공종이 사라지고, 좁히면 기준표가 공종으로 선다. 양쪽을 다 박는다. + + +def _표(headers: list[str], rows: list[list[str]] | None = None) -> dict: + return {"headers": headers, "rows": rows or [["보통인부", "인", "1.0"]]} + + +def test_참조_표지가_기준표를_잡을것() -> None: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import REFERENCE_MARKS, detect_form + + for mark in REFERENCE_MARKS: + form, why = detect_form(_표([mark, "구분", "비율"]), "13") + assert form == "reference", f"'{mark}' 를 못 잡는다 — {why}" + + +def test_단위_열이_있다고_공종을_버리지_말것() -> None: + """⚠ 2026-09-07 사고 — 헤더 「단 위」 하나로 **41건**이 참조로 버려졌다. + + 그 안에 돌쌓기(장비) `13-4-5`·`13-4-2` 처럼 **우리 매핑이 실제로 쓰는 공종**이 있었다. + 「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다. + """ + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import REFERENCE_MARKS, detect_form + + assert "단위" not in [m.replace(" ", "") for m in REFERENCE_MARKS] + form, _why = detect_form(_표(["명칭", "규격", "단 위", "수량"]), "13") + assert form != "reference" + + +def test_참조_표지를_부분일치로_넓히지_말것() -> None: + """정상 공종 이름에 표지 글자가 스쳐도 걸리면 안 된다.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form + + for headers in (["명칭", "규격", "단위", "수량"], ["직종", "단위", "수량"]): + form, _why = detect_form(_표(headers), "9") + assert form != "reference", headers + + +# ── 배수관 유입부 집수정 — 부속 줄 규칙의 짝 시험 (2026-09-08 ㉘) ────────────── + + +def test_부속은_그_칸이_있을_때만_줄이_선다() -> None: + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of + + 관 = {"structure_id": "s1", "type_id": "pipe", "options": {}} + assert attachments_of(관) == [], "집수정 형식을 안 골랐는데 줄이 섰다" + 관["options"] = {"inlet_basin_form": "돌집수정 ㄷ형"} + 붙은것 = attachments_of(관) + assert [r["type_id"] for r in 붙은것] == ["pipe_inlet_basin"] + assert 붙은것[0]["attachment_of"] == "s1" + assert 붙은것[0]["structure_id"] != "s1", "부모와 같은 id 면 두 줄이 한 줄로 뭉친다" + + +def test_부속이_없는_종류에는_아무것도_안_붙는다() -> None: + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of + + 옹벽 = {"structure_id": "s2", "type_id": "retaining_wall", "options": {"inlet_basin_form": "ㄷ형"}} + assert attachments_of(옹벽) == [] + + +# ── 빈 단가산출서 서식 (2026-09-08 ㉙, 서브가 찾아 준 것) ───────────────────── + + +def test_빈_단가산출서_서식은_공종이_아님() -> None: + """품셈이 실어 둔 **채워 넣으라고 둔 양식** — 값이 아예 없다. + + 이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다. + 실측: 이 규칙 하나로 견적 쪽 못 맞춘 자원이 **882 → 497** 로 줄었다. + """ + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form + + 표 = { + "headers": ["ha당 참나무시들음병방제 단가산출서(예시)", "", ""], + "rows": [["위치 및 임․소반", "1-0-1-0 또는 00임반 00소반", "비고"]], + } + form, why = detect_form(표, "4") + assert form == "reference", why + + +def test_머리글이_없어도_서식_모양으로_가름() -> None: + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form + + 표 = { + "headers": [], + "rows": [ + ["위치 및 면적", "00임반 00소반", "비 고"], + ["구분", "작업량", "단위품 (인원,수량,요율)", "소요품", "단가(원)"], + ], + } + assert detect_form(표, "3")[0] == "reference" + + +def test_서식_규칙이_정상_표를_지우지_말것() -> None: + """⚠ 「구분」·「단가」는 정상 표에도 흔하다 — 하나만 보면 정상 표가 사라진다.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form + + 소요량표 = {"headers": ["명칭", "규격", "단위", "수량"], "rows": [["보통인부", "", "인", "1.0"]]} + assert detect_form(소요량표, "13")[0] == "requirement" + 구분표 = { + "headers": [], + "rows": [["구 분", "소요인력", "인력구성"], ["작업로 예정선 선정", "1.0", "초급기술자"]], + } + assert detect_form(구분표, "3")[0] != "reference" + + +def test_마스터에_빈_서식이_공종으로_남아있지_않을것() -> None: + """정본 대조 — 다시 만든 마스터에 그 표가 소요량표로 서 있으면 깨진다.""" + import json + from pathlib import Path + + master = json.loads( + ( + Path(__file__).resolve().parents[2] + / "resources" + / "data_work_item_master" + / "work_item_master_2026-01-01.json" + ).read_text(encoding="utf-8") + ) + 남은것: list[str] = [] + + def walk(node: object) -> None: + if isinstance(node, dict): + for table in node.get("tables") or []: + head = "".join( + str(c) for row in (table.get("raw_row") or [])[:1] for c in row + ).replace(" ", "") + if "위치및" in head and table.get("pum_form") != "reference": + 남은것.append(str(table.get("pum_table_id"))) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + walk(master) + assert not 남은것, f"빈 서식이 아직 공종으로 서 있다: {sorted(set(남은것))}" + + +# ── 밑수가 표 아래 [주] 에만 있는 자리 (2026-09-08, B09 제보) ────────────────── + + +def test_규준틀_밑수를_표_아래_주에서_읽는다() -> None: + """⚠ 「원문에 있었음」 아홉 번째 — 이번엔 표 **아래** [주] 였다. + + 11-2·11-3 은 표에도 표 위 본문에도 밑수가 없고, [주]③ 「목재의 손율은 + **1개소 사용당** 50%로 한다」에만 「개소」가 나온다. 뜻으로도 개소가 맞다 — + [주]② 가 「본 품은 …한 비탈규준틀의 제작·도색·가설·철거를 포함한 것」이다. + """ + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source + + lines = [ + "### 11-2. 토공의 비탈 규준틀", + "| 종 류 | 단 위 | 수 량 |", + "|---|---|---|", + "| 건축목공 | 인 | 0.16 |", + "[주] ① 비탈길이 10m 이상 20m마다 설치한다.", + "- ③ 목재의 손율은 1개소 사용당 50%로 한다.", + ] + assert basis_from_source(lines, 2) == (1.0, "개소") + + +def test_개소당_평균면적_같은_흔한_말에는_안_걸린다() -> None: + """⚠ 넓히면 조림 2장이 통째로 걸린다 — 실측 19건. 「사용」이 있어야 잡는다.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source + + lines = [ + "### 2-1. 풀베기", + "| o 집단화 정도(1-4-3) | 개소당 평균면적이 1~3ha 미만 | 5% |", + ] + assert basis_from_source(lines, 2) == (None, None) + + +def test_아래_주를_보되_다음_절까지_넘어가지_말것() -> None: + """남의 [주] 를 물어 오면 조용히 틀린다.""" + from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source + + lines = [ + "### 11-9. 밑수가 없는 표", + "| 종류 | 단위 | 수량 |", + "|---|---|---|", + "### 11-2. 토공의 비탈 규준틀", + "- ③ 목재의 손율은 1개소 사용당 50%로 한다.", + ] + assert basis_from_source(lines, 2) == (None, None) + + +def test_마스터의_규준틀_둘에_밑수가_실려있을것() -> None: + """정본 대조 — 다시 만든 마스터에 값이 실제로 들어갔는가.""" + import json + from pathlib import Path + + master = json.loads( + ( + Path(__file__).resolve().parents[2] + / "resources" + / "data_work_item_master" + / "work_item_master_2026-01-01.json" + ).read_text(encoding="utf-8") + ) + 찾음: dict[str, tuple] = {} + + def walk(node: object) -> None: + if isinstance(node, dict): + code = node.get("work_item_code") + if code in ("FP-11-02", "FP-11-03"): + for table in node.get("tables") or []: + 찾음[str(code)] = (table.get("basis_quantity"), table.get("basis_unit")) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + walk(master) + assert 찾음 == {"FP-11-02": (1.0, "개소"), "FP-11-03": (1.0, "개소")}, 찾음 diff --git a/resources/tester/test_common_quantity_spread.py b/resources/tester/test_common_quantity_spread.py new file mode 100644 index 00000000..4b4b5111 --- /dev/null +++ b/resources/tester/test_common_quantity_spread.py @@ -0,0 +1,53 @@ +"""산출 요약(최소·중앙·최대) 검사 — 2026-09-07 조율 창 권고. + +이것은 **검사가 아니라 눈에 띄게 하는 장치**다. 기준을 정해 걸러 내지 않는다 — +임도 물량은 ㎥·㎡·m·ton 이 섞여 「얼마 이하면 이상하다」를 한 벌로 못 정한다. +그래서 시험도 「단위를 안 섞는가」·「없는 값을 0 으로 만들지 않는가」를 본다. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from common_util.common_util_quantity_spread import spread, spread_by_unit # noqa: E402 + + +def test_값이_없으면_0이_아니라_없음() -> None: + """0 으로 만들면 「값이 0 인 것」과 구별이 안 된다.""" + assert spread([]) is None + assert spread_by_unit([], value_key="amount") == {} + + +def test_단위를_안_섞음() -> None: + """㎥ 와 ton 을 한 통에 넣으면 최솟값이 뜻을 잃는다.""" + rows = [ + {"unit": "㎥", "amount": 2526.99}, + {"unit": "㎥", "amount": 90.51}, + {"unit": "ton", "amount": 23.89}, + ] + result = spread_by_unit(rows, value_key="amount") + assert result["㎥"]["min"] == 90.51 + assert result["㎥"]["max"] == 2526.99 + assert result["ton"]["count"] == 1 + + +def test_자릿수가_어긋난_값이_최솟값에서_드러남() -> None: + """서브가 겪은 「합계 68.8원」 같은 것 — 값이 있기는 하니 시험은 안 잡는다. + 최솟값이 나머지와 자릿수가 다르면 사람이 본다.""" + rows = [{"unit": "㎡", "amount": v} for v in (13518.6, 18952.3, 68.8)] + result = spread_by_unit(rows, value_key="amount") + assert result["㎡"]["min"] == 68.8 + assert result["㎡"]["max"] == 18952.3 + + +def test_숫자가_아닌_값은_건너뜀() -> None: + rows = [{"unit": "㎥", "amount": None}, {"unit": "㎥", "amount": 5.0}] + assert spread_by_unit(rows, value_key="amount")["㎥"]["count"] == 1 + + +def test_단위가_없으면_물음표로_모음() -> None: + assert "?" in spread_by_unit([{"amount": 1.0}], value_key="amount") diff --git a/resources/tester/test_edge_trim_3m_yonghwa.py b/resources/tester/test_edge_trim_3m_yonghwa.py new file mode 100644 index 00000000..ba8f0ef5 --- /dev/null +++ b/resources/tester/test_edge_trim_3m_yonghwa.py @@ -0,0 +1,132 @@ +"""절단 여유 30m → 3m 로 낮췄을 때 끝단 지반고가 튀지 않는지 (2026-09-04 사용자 지시). + +용화_LAS 프로젝트의 확정 지표면으로 실측한다. 판정은 「3m 로 남긴 양 끝 20m 구간의 +지반고 변화율이 노선 안쪽 구간과 같은 수준인가」 — 가장자리 점 밀도가 떨어져 값이 +못 미더우면 여기서 눈에 띄게 튄다. +""" + +import asyncio +import math +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.append(str(ROOT)) + +PROJECT_ID = "5cff3920-a181-4a3d-bec0-e0ac4082b75d" # 용화_LAS + + +def _project_root_and_surface(): + import aiomysql + + from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + from common_util.common_util_storage import resolve_stored_project_path + + async def run(): + conn = await aiomysql.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, + db=DB_NAME, charset="utf8mb4", + ) + try: + async with conn.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT storage_path FROM projects WHERE id = %s", (PROJECT_ID,) + ) + row = await cursor.fetchone() + from common_util.common_util_surface_confirmation import get_surface_confirmation_params + + params = await get_surface_confirmation_params(conn, PROJECT_ID) + return row, params + finally: + conn.close() + + row, params = asyncio.run(run()) + if not row: + pytest.skip("용화_LAS 프로젝트가 없습니다.") + return Path(resolve_stored_project_path(row["storage_path"])), params + + +def _sample_z(sampler, points): + import numpy as np + + z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64)) + return np.asarray(z, dtype=float), np.asarray(valid, dtype=bool) + + +def _slopes(sampler, points): + """이웃 정점 사이 지반고 변화율(m/m) 목록.""" + z, valid = _sample_z(sampler, points) + out = [] + for i in range(1, len(points)): + if not (valid[i] and valid[i - 1]): + continue + d = math.dist(points[i - 1], points[i]) + if d > 0.5: + out.append(abs(z[i] - z[i - 1]) / d) + return out + + +def _head_tail_within(points, metres): + """양 끝에서 `metres` 안에 드는 정점만.""" + acc = [0.0] + for i in range(1, len(points)): + acc.append(acc[-1] + math.dist(points[i - 1], points[i])) + total = acc[-1] + head = [p for p, a in zip(points, acc) if a <= metres] + tail = [p for p, a in zip(points, acc) if total - a <= metres] + return head, tail, total + + +def test_edge_trim_3m_ends_are_not_spiky(): + from common_util.common_util_route_geometry import ( + find_planned_route_file, + read_planned_route, + trim_route_to_surface, + ) + from common_util.common_util_surface_sampler import build_surface_sampler + from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + + project_root, params = _project_root_and_surface() + route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") + if route_file is None: + pytest.skip("계획노선 파일이 없습니다.") + planned = read_planned_route(route_file) + points = [(float(v.x), float(v.y)) for v in planned.vertices] + target_crs = project_epsg_from_prj(project_root) + source_crs = planned.crs_input or target_crs + if source_crs.upper() != target_crs.upper(): + from pyproj import Transformer + + transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True) + points = [transformer.transform(x, y) for x, y in points] + + sampler = build_surface_sampler( + project_root / "B04_PreProcess" / "models", + str(params["source_filter"]), + str(params["method"]), + bool(params["smooth"]), + ) + + trimmed_30 = trim_route_to_surface(points, sampler, 30.0) + trimmed_3 = trim_route_to_surface(points, sampler, 3.0) + assert len(trimmed_3) >= 2 + head, tail, total_3 = _head_tail_within(trimmed_3, 20.0) + _, _, total_30 = _head_tail_within(trimmed_30, 20.0) + print(f"\n연장: 30m 트림 {total_30:.1f}m / 3m 트림 {total_3:.1f}m (차이 {total_3 - total_30:.1f}m)") + + inner = _slopes(sampler, trimmed_3[len(trimmed_3) // 4 : 3 * len(trimmed_3) // 4]) + ends = _slopes(sampler, head) + _slopes(sampler, tail) + assert inner and ends + inner_max = max(inner) + ends_max = max(ends) + print(f"지반고 변화율 최대: 안쪽 {inner_max:.3f} / 양 끝 20m {ends_max:.3f}") + # 끝단이 안쪽보다 크게 튀지 않아야 한다(가장자리 밀도 저하 확인). + assert ends_max <= max(inner_max * 1.5, inner_max + 0.05) + + +def test_config_default_is_3m(): + from config.config_system_terrain import SURFACE_ROUTE_EDGE_TRIM_M + + assert SURFACE_ROUTE_EDGE_TRIM_M == 3.0 diff --git a/resources/tester/test_initial_design_failed_email.py b/resources/tester/test_initial_design_failed_email.py new file mode 100644 index 00000000..b4b67624 --- /dev/null +++ b/resources/tester/test_initial_design_failed_email.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +"""초기 설계 실패 안내 메일 — 완료 메일 대신 나가는지, 문구에 관리자 문의가 있는지. + +실제 SMTP는 타지 않는다. 확인 대상은 발송 여부 판정과 본문 규약이다. +""" + +import asyncio + +import pytest + +import B03_FileInput.B03_FileInput_Email as email_mod + + +@pytest.fixture +def sent(monkeypatch): + captured = {} + + async def _fake_send_email(*, to_email, subject, html): + captured["to"] = to_email + captured["subject"] = subject + captured["html"] = html + return True + + monkeypatch.setattr(email_mod, "send_email", _fake_send_email) + return captured + + +def test_failed_email_carries_reason_and_admin_contact(sent, monkeypatch): + monkeypatch.setattr(email_mod, "ADMIN_EMAIL", "admin@example.com") + + ok = asyncio.run( + email_mod.send_initial_design_failed_email( + project_id="325f57d9-0617-49b9-a793-983f24781e1e", + project_name="시제 프로젝트", + to_email="user@example.com", + reason="B06 초기 횡단 확정 실패 (status=404).", + ) + ) + + assert ok is True + assert sent["to"] == "user@example.com" + assert "초기 설계 실패 알림" in sent["subject"] + assert "B06 초기 횡단 확정 실패 (status=404)." in sent["html"] + assert "관리자에게 문의" in sent["html"] + assert "admin@example.com" in sent["html"] + # 되돌릴 초기값이 없다는 사실을 본문에서 알려야 한다. + assert "초기값" in sent["html"] + + +def test_failed_email_without_admin_address_still_guides(sent, monkeypatch): + monkeypatch.setattr(email_mod, "ADMIN_EMAIL", "") + + asyncio.run( + email_mod.send_initial_design_failed_email( + project_id="p1", + project_name="주소 없는 환경", + to_email="user@example.com", + reason="계획노선 CSV가 없어 초기 노선을 세울 수 없습니다.", + ) + ) + + assert "관리자에게 문의" in sent["html"] + assert "mailto:" not in sent["html"] diff --git a/resources/tester/test_preview_assets.py b/resources/tester/test_preview_assets.py new file mode 100644 index 00000000..fc0008c7 --- /dev/null +++ b/resources/tester/test_preview_assets.py @@ -0,0 +1,75 @@ +"""카드 미리보기 재료(라이다 점 그림·GeoTIFF 썸네일) 생성 검증 (2026-09-04 사용자 지시).""" + +import asyncio +import sys +import time +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.append(str(ROOT)) + +PROJECT_ID = "5cff3920-a181-4a3d-bec0-e0ac4082b75d" # 용화_LAS + + +def _project_root() -> Path: + import aiomysql + + from common_util.common_util_storage import resolve_stored_project_path + from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + + async def run(): + conn = await aiomysql.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, + db=DB_NAME, charset="utf8mb4", + ) + try: + async with conn.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT storage_path FROM projects WHERE id = %s", (PROJECT_ID,) + ) + return await cursor.fetchone() + finally: + conn.close() + + row = asyncio.run(run()) + if not row: + pytest.skip("용화_LAS 프로젝트가 없습니다.") + return Path(resolve_stored_project_path(row["storage_path"])) + + +def test_las_preview_points(): + from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_las_metadata + + root = _project_root() + files = sorted((root / "B03_FileInput" / "input" / "las").glob("*.la*")) + if not files: + pytest.skip("LAS 파일이 없습니다.") + started = time.perf_counter() + meta = analyze_las_metadata(files[0]) + elapsed = time.perf_counter() - started + points = meta.get("preview_points") or [] + print(f"\n{files[0].name}: 점 {meta['point_count']:,}개, 미리보기 점 {len(points)}개, {elapsed:.1f}s") + assert 0 < len(points) <= 5000 + bounds = meta["bounds"] + for x, y in points[:200]: + assert bounds["x"][0] - 1 <= x <= bounds["x"][1] + 1 + assert bounds["y"][0] - 1 <= y <= bounds["y"][1] + 1 + + +def test_geotiff_thumbnail(): + from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_tif_metadata + + root = _project_root() + files = sorted((root / "B03_FileInput" / "input" / "tif").glob("*.tif")) + if not files: + pytest.skip("GeoTIFF 파일이 없습니다.") + started = time.perf_counter() + meta = analyze_tif_metadata(files[0]) + elapsed = time.perf_counter() - started + thumb = meta.get("preview_thumbnail") + print(f"{files[0].name}: 썸네일 {'있음 %d바이트' % len(thumb) if thumb else '없음(오버뷰 없음)'}, {elapsed:.1f}s") + if thumb: + assert thumb.startswith("data:image/png;base64,") + assert elapsed < 5.0 diff --git a/resources/tester/test_preview_cost.py b/resources/tester/test_preview_cost.py new file mode 100644 index 00000000..ffe87418 --- /dev/null +++ b/resources/tester/test_preview_cost.py @@ -0,0 +1,50 @@ +"""미리보기 점 수집이 업로드 분석 시간을 늘리지 않는지 (2026-09-04 사용자 제약).""" + +import sys +import time +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.append(str(ROOT)) + + +def _las_file() -> Path: + from tmp.tests.test_preview_assets import _project_root # noqa: PLC0415 + + root = _project_root() + files = sorted((root / "B03_FileInput" / "input" / "las").glob("*.la*")) + if not files: + pytest.skip("LAS 파일이 없습니다.") + return files[0] + + +def test_collect_cost_is_small(): + import laspy + import numpy as np + + path = _las_file() + # ① 분류 통계만 (종전 동작) + started = time.perf_counter() + with laspy.open(path) as las_file: + counts: dict[int, int] = {} + for chunk in las_file.chunk_iterator(500_000): + values, chunk_counts = np.unique( + np.asarray(chunk.classification, dtype=np.uint8), return_counts=True + ) + for value, count in zip(values.tolist(), chunk_counts.tolist(), strict=True): + counts[value] = counts.get(value, 0) + count + baseline = time.perf_counter() - started + + # ② 지금 동작 (같은 길에 XY 를 성기게 주움) + from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_las_metadata + + started = time.perf_counter() + meta = analyze_las_metadata(path) + current = time.perf_counter() - started + + print(f"\n분류 통계만 {baseline:.2f}s / 점 수집 포함 {current:.2f}s " + f"(차이 {current - baseline:+.2f}s, 점 {len(meta['preview_points'])}개)") + # 파일을 다시 읽지 않으므로 한 번 훑는 시간과 크게 다르지 않아야 한다. + assert current <= baseline * 1.5 + 1.0 diff --git a/resources/tester/test_route_range_clip.py b/resources/tester/test_route_range_clip.py new file mode 100644 index 00000000..e2c1d48b --- /dev/null +++ b/resources/tester/test_route_range_clip.py @@ -0,0 +1,49 @@ +"""계획노선 사용 범위 절단 (2026-09-04 사용자 지시) 단위 검증.""" + +import math +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parents[2])) + +from common_util.common_util_route_geometry import clip_route_by_chainage + + +def _length(points): + return sum(math.dist(points[i - 1], points[i]) for i in range(1, len(points))) + + +LINE = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)] # 총 200m + + +def test_no_range_keeps_all(): + assert clip_route_by_chainage(LINE, None, None) == LINE + + +def test_start_only(): + out = clip_route_by_chainage(LINE, 50.0, None) + assert out[0] == (50.0, 0.0) + assert out[-1] == (100.0, 100.0) + assert abs(_length(out) - 150.0) < 1e-6 + + +def test_end_only(): + out = clip_route_by_chainage(LINE, None, 150.0) + assert out[0] == (0.0, 0.0) + assert abs(out[-1][0] - 100.0) < 1e-6 and abs(out[-1][1] - 50.0) < 1e-6 + assert abs(_length(out) - 150.0) < 1e-6 + + +def test_both_ends_and_interpolated_vertex(): + out = clip_route_by_chainage(LINE, 50.0, 150.0) + assert abs(_length(out) - 100.0) < 1e-6 + # 꺾임점(100,0)이 구간 안이면 그대로 남는다. + assert (100.0, 0.0) in [(round(x, 6), round(y, 6)) for x, y in out] + + +def test_range_outside_data_falls_back_to_full(): + assert clip_route_by_chainage(LINE, 500.0, 900.0) == LINE + + +def test_reversed_range_falls_back_to_full(): + assert clip_route_by_chainage(LINE, 150.0, 50.0) == LINE diff --git a/resources/tester/test_route_range_real.py b/resources/tester/test_route_range_real.py new file mode 100644 index 00000000..2d08d539 --- /dev/null +++ b/resources/tester/test_route_range_real.py @@ -0,0 +1,75 @@ +"""실제 노선(용화_LAS)에 사용 범위를 걸면 그 구간만 남는지 (2026-09-04 사용자 지시).""" + +import asyncio +import math +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.append(str(ROOT)) + +PROJECT_ID = "5cff3920-a181-4a3d-bec0-e0ac4082b75d" # 용화_LAS + + +def _project_root() -> Path: + import aiomysql + + from common_util.common_util_storage import resolve_stored_project_path + from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + + async def run(): + conn = await aiomysql.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, + db=DB_NAME, charset="utf8mb4", + ) + try: + async with conn.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT storage_path FROM projects WHERE id = %s", (PROJECT_ID,) + ) + return await cursor.fetchone() + finally: + conn.close() + + row = asyncio.run(run()) + if not row: + pytest.skip("용화_LAS 프로젝트가 없습니다.") + return Path(resolve_stored_project_path(row["storage_path"])) + + +def test_range_clips_real_route(): + from common_util.common_util_route_geometry import load_design_route + + project_root = _project_root() + # surface_params 없이 = 정본 CSV 지름길을 타지 않는 원본 판독 경로. + full = load_design_route(project_root) + if full is None: + pytest.skip("계획노선을 읽지 못했습니다.") + total = full.vertices[-1].chainage_m + clipped = load_design_route(project_root, None, (100.0, 400.0)) + assert clipped is not None + length = clipped.vertices[-1].chainage_m + print(f"\n전 구간 {total:.1f}m → 범위 100~400m 적용 {length:.1f}m") + assert abs(length - 300.0) < 1.0 + # 시점이 원 노선의 100m 자리와 같은 좌표인지. + walked = 0.0 + target = None + for index in range(1, len(full.vertices)): + a = full.vertices[index - 1] + b = full.vertices[index] + step = math.dist((a.x, a.y), (b.x, b.y)) + if walked + step >= 100.0: + ratio = (100.0 - walked) / (step or 1) + target = (a.x + (b.x - a.x) * ratio, a.y + (b.y - a.y) * ratio) + break + walked += step + assert target is not None + start = clipped.vertices[0] + assert math.dist((start.x, start.y), target) < 0.5 + + # 비우면 전 구간 그대로. + same = load_design_route(project_root, None, (None, None)) + assert same is not None + assert abs(same.vertices[-1].chainage_m - total) < 1e-6