"""구조물 원단위 전개식 검사 — 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). 맞추려고 식을 비틀지 않는다 — 허용오차를 그 차이만큼 둔다. """ # 실무 시트가 뒷길이 45 — 2026-09-14 부터 안 고르면 표준 하한(찰 ≤1.5m 25㎝)이라 고름. result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45)) 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, back_len_cm=45)) # 터파기 = 높이 × (평균두께 + 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-13 브레인 판정 — 계수 열 고르기와 돌종류는 다른 축. 옛 전개는 「실무 관행」을 고르면 야면석이 「돌」·계산식 무게(0.918)로 조용히 섰음. 이제 계수만 참고자료 열(고임돌 0.15)로 가고, 돌은 야면석 규격·관측 무게(0.88)를 지킴. """ result = expand( 돌쌓기찰( height=2.0, length=10.0, back_len_cm=45, stone_kind="야면석·호박돌", stone_coeff_basis="실무 관행", face_slope_ratio=0.3, ) ) 돌쌓기 = 성분(result, "돌쌓기").amount 돌 = 성분(result, "돌") # 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ) assert 돌.spec == "야면석·호박돌" assert 돌.amount == pytest.approx(돌쌓기 * 0.88) assert 성분(result, "고임돌").amount == pytest.approx(돌쌓기 * 0.15) # 관행 열 def test_물구멍관_길이는_평균두께() -> None: """2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = 평균두께(옛 상수 0.5m 아님). H=2.0 · 뒷길이 45 → 상부 0.75 · 하부 1.05 · 평균 0.90. 1:0.3 · 2㎡/개소. """ result = expand(돌쌓기찰(height=2.0, length=10.0, back_len_cm=45, face_slope_ratio=0.3)) 돌쌓기 = 성분(result, "돌쌓기").amount 물구멍관 = 성분(result, "물구멍관") assert 물구멍관.amount == pytest.approx(돌쌓기 / 2.0 * 0.90) assert "평균두께 0.90" 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)