Files
Aislo/resources/tester/test_b08_unit_quantity.py
eomsangdonandClaude Opus 5 d7cb14f416 test(B08): tmp/tests 중 tester 에 없던 47 개를 resources/tester 로 옮김
tmp/ 가 창끼리 안 건너가는 것이 확정돼(랩탑이 시간 두고 두 번 확인) 시험·예외가
저절로 건너가도록 git 안으로 옮김. 사용자 확정.

- 내용은 하나도 안 고침 — 자리만 옮김. tmp/tests 는 남겨 둠.
- 같은 이름이 이미 있던 64 개는 랩탑 것을 그대로 두고 건너뜀.
- helper_b05_*.js 둘은 랩탑이 .cjs 로 이미 올린 것과 **줄바꿈만 다른 같은 내용**이라
  복사본을 도로 뺌(시험이 .cjs 를 부름).
- resources/tester/ 에서 전체 1176 통과 · 29 건너뜀 · 실패 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:15:57 +09:00

411 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""구조물 원단위 전개식 검사 — 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)