- 등록부 box_culvert: 연장(기본값 없음) · 측벽 0.3 · 상·저판 0.25 · 헌치 0.2 · 버림 0.1 — 제안값 = 산림과임업기술(임도) 5장 수량산출 예제 · KDS 44 90 00 300㎜ 병기(판 0.25 는 못 미침을 나란히) - B06 BOX 세트(파이썬·TS 짝)가 두께 칸을 읽음 — 종전 「세월교 값 승계」(벽 0.2·판 0.3) 걷음 · 그림 따라감 - B08 전개식 `_UnitQuantity_Box`: 콘크리트 · 버림 · 유로폼(외벽·내벽·상판 밑·귀면) · 버림 옆 합판 · 동바리 · 비계(시종점 두 면은 개소당 한 번) · 철근은 울진 2×2 관측(D13 179·D16 157kg/m)만, 나머지 크기는 「크기별 식 없음」 - 울진 기번3 「암거수량집계표(2×2)」 m당과 맞음: 레미콘 2.84 · 버림 0.28 · 유로폼 11.131 · 합판 0.2 · 동바리 3.92 · 비계 벽체 5.2 - 치수 칸을 안 적은 BOX 는 제안값으로 서되 미확정(금액 밖) · 연장이 비면 안 섬 - 묶음 조각 12-01-01·12-38·12-04(6회)·12-03·12-20·12-19·12-25 · 동바리 대상 표시 · 세월교 「수량 근거 없음」 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
204 lines
8.6 KiB
Python
204 lines
8.6 KiB
Python
"""관측 원단위표 검사 — 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))
|
||
# 원문 값 1.345 ㎥/m × 10 · 유로폼 3.205(2026-09-14 · 전 라이브러리 1.35 → 13.5 · 3.20 → 32.0)
|
||
assert 성분(result, "콘크리트").amount == pytest.approx(13.45)
|
||
assert 성분(result, "유로폼").amount == pytest.approx(32.05)
|
||
|
||
|
||
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"]]
|
||
# 2026-09-14 — 단면으로 세운 터파기·되메우기·잔토도 파생 줄(브레인 판정 ①).
|
||
derived = {"기초잡석", "터파기", "되메우기", "잔토처리"}
|
||
kinds = {c["basis_kind"] for c in components if c["name"] not in derived}
|
||
assert kinds == {BASIS_OBSERVED}
|
||
assert {c["basis_kind"] for c in components if c["name"] in derived} == {BASIS_DERIVED}
|
||
잡석 = 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:
|
||
"""2026-09-15 — 두께 칸이 등록부에 서고 전개식(`_UnitQuantity_Box`)이 생겨 관측표 몫이 아님.
|
||
|
||
관측값은 여전히 없음(울진 2×2 철근만 전개식 안에 관측값으로 듦) · 세월교는 사유로 닫힘.
|
||
"""
|
||
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"]: item for item in table.not_found["items"]}
|
||
assert "box_culvert" not in reasons
|
||
assert "세월교 수량 근거 없음" in reasons["ford_bridge"]["why"]
|
||
|
||
|
||
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
|