Files
Aislo/resources/tester/test_b07_masshaul_basin.py
T
eomsangdonandClaude Opus 5 0ef32b5279 chore(tester): 시험을 resources/tester/ 로 옮김 — 창끼리 건너가게
⚠ **뿌리** — `tmp/` 는 창 사이에 안 건너감(실측 확인: 상대 창이 놓은 `tmp/_sync_probe.txt`
가 시간을 두고 두 번 봐도 안 보임). 그래서 **정본(등록부 스키마)만 건너가고 그것을 읽는
시험은 안 건너가** 오늘 두 번, 같은 시험이 **연 창은 통과·받은 창은 실패**가 됐음.

- `tmp/tests/*` 를 `resources/tester/` 로 **복사**(127 파일). 내용은 **한 줄도 안 고침**
- `tmp/tests` 는 **남겨 둠** — 되돌릴 자리(사용자 지시)
- 실행: `./venv/Scripts/python.exe -m pytest resources/tester/ -q`
  옮기기 전과 **같은 수**: 617 통과 / 22 건너뜀 / 실패 0

⇒ 이제 시험·예외·까닭이 **정본과 함께** 움직임. 오늘 세운 「예외는 정본 스키마에」와 짝임.

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

321 lines
12 KiB
Python

"""B07 토적도·유역도 엔진 — 좌표 환산과 표기가 척도대로 나오는지 확인한다."""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import (
MM_V,
auto_scale_h,
build_mass_haul_drawing,
)
def _texts(drawing):
"""도면 안 모든 글자(자식 포함) — Text 라벨과 Table 칸 글자를 함께 훑는다.
정보표는 선·문자를 따로 두지 않고 Table 엔티티 하나로 나간다(DXF의 표로 내보내기
위함) — 칸 글자는 `shapeData.cells` 안에 있어 Text만 훑으면 통째로 안 보인다.
"""
out = []
def walk(entity):
if entity.get("type") == "Text":
out.append(entity["shapeData"]["label"])
if entity.get("type") == "Table":
for row in entity["shapeData"].get("cells") or []:
out.extend(cell["text"] for cell in row if cell and cell.get("text") is not None)
for child in entity.get("children") or []:
walk(child)
for entity in drawing["entities"]:
walk(entity)
return out
def _lines(drawing, layer_id):
"""레이어의 Line 좌표쌍 목록(PolyLine 자식 포함)."""
out = []
def walk(entity):
if entity.get("type") == "Line" and entity.get("layerId") == layer_id:
shape = entity["shapeData"]
out.append(
(
(shape["startPoint"]["x"], shape["startPoint"]["y"]),
(shape["endPoint"]["x"], shape["endPoint"]["y"]),
)
)
for child in entity.get("children") or []:
walk(child)
for entity in drawing["entities"]:
walk(entity)
return out
LONGITUDINAL = {
"stations": [
{"chainage_m": 0.0, "station_id": "s0"},
{"chainage_m": 20.0, "station_id": "s1"},
{"chainage_m": 40.0, "station_id": "s2"},
]
}
MASS_HAUL = {
"basis": "compacted",
"points": [
{"station_id": "s0", "chainage_m": 0.0, "cumulative_volume_m3": 0.0},
{"station_id": "s1", "chainage_m": 20.0, "cumulative_volume_m3": 1000.0},
{"station_id": "s2", "chainage_m": 40.0, "cumulative_volume_m3": 400.0},
],
"haul_plan": {
"blocks": [
{
"index": 1,
"from_m": 0.0,
"to_m": 40.0,
"base_m3": 0.0,
"volume_m3": 1000.0,
"direction": "forward",
"bands": [
{
"index": 1,
"equipment": "free_haul",
"volume_m3": 600.0,
"haul_distance_m": 18.5,
"ea_m3": 400.0,
"rr_m3": 150.0,
"br_m3": 50.0,
"level_base_m3": 0.0,
"level_apex_m3": 600.0,
"boundary_from_m": 0.0,
"boundary_to_m": 40.0,
"haul_from_m": 6.0,
"haul_to_m": 34.0,
}
],
}
],
"residuals": [
{
"index": 1,
"kind": "spoil",
"from_m": 30.0,
"to_m": 40.0,
"volume_m3": 120.0,
"natural_m3": 40.0,
"level_from_m3": 400.0,
"level_to_m3": 280.0,
"ea_m3": 80.0,
"rr_m3": 30.0,
"br_m3": 10.0,
}
],
"transfers": [],
},
}
def test_mass_haul_scale_follows_route_length():
"""가로 축척은 연장으로 정한다 — 한 장에 들어가는 가장 큰 그림(2026-09-03 사용자 결정)."""
assert auto_scale_h(40.0) == 500 # 40m -> 80mm
assert auto_scale_h(1106.0) == 2000 # 용화 1,106m -> 553mm (1:1,500이면 737mm로 넘침)
assert auto_scale_h(3465.0) == 5000
assert MM_V == 0.02 # 세로는 고정(종이 1mm = 50㎥)
def test_mass_haul_curve_uses_paper_scale():
"""유토곡선 점은 x=거리xmm_h, y=토량/50mm로 옮겨진다. 40m 노선이면 H 1/500 -> 2.0mm/m."""
mm_h = 1000.0 / auto_scale_h(40.0)
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
curve = _lines(drawing, "b08-masshaul-curve")
assert curve[0][0] == (0.0, 0.0)
assert curve[0][1] == (20.0 * mm_h, 1000.0 * 0.02)
assert curve[1][1] == (40.0 * mm_h, 400.0 * 0.02)
def test_mass_haul_balloon_texts():
"""balloon 표기를 납품 도면에 맞춘다 — 종무대·등호 뒤 한 칸·M.N 소수 2자리
(2026-09-03 사용자 확정 8행)."""
labels = _texts(build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul"))
assert "종무대" in labels # 장비명 뒤 숫자는 정체 미확인이라 적지 않는다
assert "Q= 600.00M3" in labels
assert "L= 18.50M" in labels
assert "EA= 400.00M3" in labels
assert "사토 1" in labels
assert "M.N= 1+10.00" in labels # 측점 간격 20m 기준 30m 지점
assert "유 토 곡 선" in labels
assert "SCALE H=1:500 V=1:50,000" in labels # 40m 노선의 자동 축척
def test_mass_haul_table_marks_match_delivery_form():
"""측점은 `n+ 0.0`, 행 이름은 자간을 벌린 큰 글씨, 눈금은 정규 빨강·추가 회색."""
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
labels = _texts(drawing)
assert "1+ 0.0" in labels and "2+ 0.0" in labels
assert not any(text.startswith("No.") for text in labels)
assert "누 가 토 량" in labels and "측 점" in labels
# 눈금은 표 레이어의 짧은 세로선 — 정규 측점은 빨강(#ff4d4d).
ticks = [
entity
for entity in drawing["entities"]
if entity.get("layerId") == "b08-masshaul-table"
and entity.get("type") == "Line"
and entity.get("lineColor") == "#ff4d4d"
]
assert ticks, "표 눈금이 없다"
for tick in ticks:
shape = tick["shapeData"]
assert abs(shape["startPoint"]["x"] - shape["endPoint"]["x"]) < 1e-9 # 세로선
assert abs(shape["startPoint"]["y"] - shape["endPoint"]["y"]) == 2.0
def test_mass_haul_leader_is_stepped_from_corner():
"""지시선은 balloon 모서리에서 나가는 계단형(가로 → 세로) 폴리선이다."""
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
# 지시선은 띠 레이어의 자식 2개짜리 PolyLine(가로 → 대각) 이다.
leaders = [
entity
for entity in drawing["entities"]
if entity.get("type") == "PolyLine"
and entity.get("layerId") == "b08-masshaul-band"
and len(entity.get("children") or []) == 2
]
assert leaders, "지시선이 없다"
for leader in leaders:
first = leader["children"][0]["shapeData"]
assert abs(first["startPoint"]["y"] - first["endPoint"]["y"]) < 1e-9 # 첫 구간은 수평
def test_mass_haul_band_chords_sit_at_stored_levels():
"""경계현은 level_base, 운반현은 띠 중간 높이에 저장값 그대로 놓인다."""
drawing = build_mass_haul_drawing(LONGITUDINAL, MASS_HAUL, "mass_haul")
chords = _lines(drawing, "b08-masshaul-band")
mm_h = 1000.0 / auto_scale_h(40.0)
assert ((0.0, 0.0), (40.0 * mm_h, 0.0)) in chords # 경계현 level_base=0
assert ((6.0 * mm_h, 300.0 * 0.02), (34.0 * mm_h, 300.0 * 0.02)) in chords # 중간 300㎥
BASINS = [
{
"ring": [(100.0, 100.0), (400.0, 100.0), (400.0, 400.0), (100.0, 400.0)],
"props": {
"kind": "detail_basin",
"index": 1,
"chainage_m": 120.0,
"area_m2": 15000.0,
"relief_m": 165.0,
"flow_length_m": 210.0,
"pipe_diameter_mm": 800,
"bridge_required": False,
},
}
]
def test_watershed_info_box_matches_delivery_form():
"""정보표는 유역면적 ha 환산·유역표고·유하거리·배수규격을 납품 표기로 적는다."""
drawing = build_watershed_drawing(
"watershed",
route_xy=[(0.0, 0.0), (500.0, 0.0)],
basins=BASINS,
contours=[[(0.0, 50.0), (500.0, 60.0)]],
streams=[[(200.0, 0.0), (200.0, 400.0)]],
interval_m=20.0,
)
labels = _texts(drawing)
# 측점 표기는 토적도와 같은 `n+ 0.0`(2026-09-03 사용자 확정 — 유역도까지 통일).
assert "(1) 6+ 0.0" in labels
assert "배수규격 Φ800mm" in labels
assert "1.50 ha" in labels # 15,000㎡
assert "165.0 m" in labels
assert "210.0 m" in labels
assert "수 리 집 수 면 적 유 역 도" in labels
assert "S = 1/6,000" in labels
def test_watershed_route_uses_plan_scale():
"""평면 좌표는 콘텐츠 최소점을 원점으로 1/6,000 (1m = 1/6mm)로 옮겨진다."""
drawing = build_watershed_drawing(
"watershed",
route_xy=[(0.0, 0.0), (600.0, 0.0)],
basins=[],
contours=[],
streams=[],
)
route = _lines(drawing, "b08-basin-route")
assert route[0][0] == (0.0, 0.0)
assert abs(route[0][1][0] - 100.0) < 1e-9 # 600m / 6 = 100mm
def test_clip_line_to_box_splits_outside_runs():
"""도엽 등고선처럼 긴 선은 범위 안 구간만 남고, 끝점은 경계 위에 정확히 놓인다.
2026-08-31 이전에는 경계 바깥 점을 하나씩 물고 나와 외곽이 들쭉날쭉했다.
지금은 교차점을 계산해 자른다(Liang-Barsky).
"""
from B07_DesignDetail.B07_DesignDetail_Router_Support import clip_line_to_box
box = (0.0, 0.0, 10.0, 10.0)
line = [(-5.0, 5.0), (2.0, 5.0), (5.0, 5.0), (20.0, 5.0), (30.0, 5.0), (8.0, 5.0)]
runs = clip_line_to_box(line, box)
assert runs == [
[(0.0, 5.0), (2.0, 5.0), (5.0, 5.0), (10.0, 5.0)],
[(10.0, 5.0), (8.0, 5.0)],
]
assert clip_line_to_box([(50.0, 50.0), (60.0, 60.0)], box) == []
# 남은 점이 상자를 벗어나지 않는다
for run in runs:
for x, y in run:
assert 0.0 - 1e-9 <= x <= 10.0 + 1e-9
assert 0.0 - 1e-9 <= y <= 10.0 + 1e-9
def test_watershed_diameter_label_snaps_to_standard_size():
"""소요 관경(Φ929)만 있는 옛 저장본도 도면에는 규격관(Φ1000)으로 적는다."""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import _diameter_label
assert _diameter_label({"pipe_diameter_mm": 929.4}) == "Φ1000mm"
assert (
_diameter_label({"pipe_diameter_mm": 929.4, "recommended_diameter_mm": 1200}) == "Φ1200mm"
)
assert _diameter_label({"pipe_diameter_mm": 2000}) == "Φ1500mm"
assert _diameter_label({"bridge_required": True, "pipe_diameter_mm": 1600}) == "물넘이포장"
def test_watershed_number_badge_is_circle_with_disc():
"""유역 번호는 흰 원판(solid 해치) + 원 테두리 + 숫자로 그려 해칭 위에서도 읽힌다."""
drawing = build_watershed_drawing(
"watershed", route_xy=[(0.0, 0.0), (500.0, 0.0)], basins=BASINS, contours=[], streams=[]
)
# 방위표 템플릿에도 원이 있으므로 유역 레이어만 센다.
circles = [
e
for e in drawing["entities"]
if e.get("type") == "Circle" and e.get("layerId") == "b08-basin-area"
]
assert len(circles) == 1
assert circles[0]["shapeData"]["radius"] == 3.6
disc = [
e
for e in drawing["entities"]
if e.get("type") == "Hatch" and e["shapeData"]["options"]["style"] == "solid"
]
assert len(disc) == 1
assert "1" in _texts(drawing)
def test_watershed_basin_has_hatch():
"""유역마다 격자 해칭(Hatch 엔티티)이 경계선과 함께 들어간다."""
drawing = build_watershed_drawing(
"watershed", route_xy=[(0.0, 0.0), (500.0, 0.0)], basins=BASINS, contours=[], streams=[]
)
hatches = [
e
for e in drawing["entities"]
if e.get("type") == "Hatch" and e["shapeData"]["options"]["style"] == "cross"
]
assert len(hatches) == 1
shape = hatches[0]["shapeData"]
assert shape["options"]["style"] == "cross"
assert len(shape["points"]) == 4
assert hatches[0]["layerId"] == "b08-basin-area"