"""사토장 — 용량에서 폭을 정해 측점마다 단면을 세운다 (2026-09-09). ⚠ 잠그는 것 ① 용량이 커질수록 폭이 넓어지고, 상한에서 멈추면 못 담은 몫이 값으로 남는다. ② 사토장이 선 측점은 **노선 성토가 줄고** 그만큼이 사토장 몫으로 간다(두 번 세지 않기). ③ 「자동」이면 그 측점의 **성토 쪽**에 선다. ④ 구간 밖 측점은 안 건드린다 — **새 측점을 만들지 않는다**. """ from __future__ import annotations import json import sys from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills # noqa: E402 def _samples() -> list[dict]: """우측(-offset)이 높고 좌측(+offset)이 낮은 지반 — 좌가 성토 쪽이다.""" return [ {"offset_m": round(offset, 3), "elevation_m": round(100.0 - 0.5 * offset, 4), "valid": True} for offset in [x * 0.5 for x in range(-30, 31)] ] def _sections(chainages: list[float]) -> list[dict]: return [ { "chainage_m": chainage, "samples": _samples(), "design": compute_cross_design( _samples(), 99.8, ground_type="soil", section_mode="right_cut", ditch_side="right", ), } for chainage in chainages ] _LONGITUDINAL = { "design_profiles": [ {"samples": [{"chainage_m": float(c), "elevation_m": 99.8} for c in range(0, 201, 20)]} ] } def _write_site(root: Path, capacity_m3: float, **options) -> None: (root / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True) (root / "B05_Profile" / "route" / "structures.json").write_text( json.dumps( { "revision": 1, "structures": [ { "structure_id": "sp-1", "type_id": "spoil_bank", "placement": "interval", "start_m": 100.0, "end_m": 160.0, "options": {"capacity_m3": capacity_m3, **options}, } ], }, ensure_ascii=False, ), encoding="utf-8", ) def _run(root: Path, capacity_m3: float, **options) -> list[dict]: _write_site(root, capacity_m3, **options) sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0]) enforce_spoil_fills(_LONGITUDINAL, sections, root) return sections def _placed(sections: list[dict]) -> float: inside = [s for s in sections if 100.0 <= s["chainage_m"] <= 160.0] return float(inside[0]["design"].get("spoil_fill_placed_m3") or 0.0) def test_용량이_클수록_폭이_넓어진다(tmp_path: Path) -> None: small = _run(tmp_path, 200.0) large = _run(tmp_path, 800.0) narrow = small[1]["design"]["spoil_fill_width_m"] wide = large[1]["design"]["spoil_fill_width_m"] assert 0 < narrow < wide assert _placed(small) == pytest.approx(200.0, rel=0.02) assert _placed(large) == pytest.approx(800.0, rel=0.02) def test_담을_수_없으면_남은_몫이_값으로_남는다(tmp_path: Path) -> None: """상한에서 멈춘다 — 임의로 더 넓히지 않는다.""" sections = _run(tmp_path, 1_000_000.0) design = sections[1]["design"] assert design["spoil_fill_unplaced_m3"] > 0 assert design["spoil_fill_width_m"] == pytest.approx(design["spoil_fill_max_width_m"]) def test_노선_성토가_그만큼_줄어든다(tmp_path: Path) -> None: """같은 흙을 두 번 세지 않는다(확정 ㉠).""" plain = _sections([120.0])[0]["design"] sections = _run(tmp_path, 400.0) inside = sections[2]["design"] assert inside["spoil_fill_area_m2"] > 0 assert inside["spoil_fill_replaced_fill_m2"] > 0 assert inside["fill_area_m2"] == pytest.approx( plain["fill_area_m2"] - inside["spoil_fill_replaced_fill_m2"], abs=1e-6 ) def test_자동이면_성토_쪽에_선다(tmp_path: Path) -> None: sections = _run(tmp_path, 400.0, side="자동(성토 쪽)") # right_cut = 우측 절토 ⇒ 성토는 좌측(+offset). assert sections[1]["design"]["spoil_fill_side"] == "left" def test_구간_밖_측점은_안_건드린다(tmp_path: Path) -> None: """새 측점을 만들지 않고, 구간 밖은 종전 그대로다(확정 ③).""" sections = _run(tmp_path, 400.0) assert len(sections) == 6 for section in sections: design = section["design"] if 100.0 <= section["chainage_m"] <= 160.0: assert design["spoil_fill_area_m2"] > 0 else: assert design.get("spoil_fill_area_m2", 0) == 0 def test_용량이_없으면_아무것도_안_세운다(tmp_path: Path) -> None: (tmp_path / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True) (tmp_path / "B05_Profile" / "route" / "structures.json").write_text( json.dumps( { "revision": 1, "structures": [ { "structure_id": "sp-1", "type_id": "spoil_bank", "placement": "interval", "start_m": 100.0, "end_m": 160.0, "options": {}, } ], } ), encoding="utf-8", ) sections = _sections([120.0]) assert enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) == 0 assert sections[0]["design"]["spoil_fill_area_m2"] == 0 def test_사토장을_지우면_값도_사라진다(tmp_path: Path) -> None: """⚠ 화면 실측으로 잡은 자리(2026-09-09) — 얹기만 하고 **지우지 않아** 구조물을 없앤 뒤에도 횡단도에 계속 그려지고 면적표에도 서 있었다. """ sections = _run(tmp_path, 400.0) inside = sections[2]["design"] assert inside["spoil_fill_area_m2"] > 0 plain_fill = _sections([120.0])[0]["design"]["fill_area_m2"] # 구조물을 지운다 — 빈 목록으로 덮어쓴다. (tmp_path / "B05_Profile" / "route" / "structures.json").write_text( json.dumps({"revision": 2, "structures": []}, ensure_ascii=False), encoding="utf-8" ) changed = enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) assert changed > 0, "지운 사토장을 되돌리지 않았다" for section in sections: design = section["design"] # 칸은 남아도 **값이 0** 이어야 한다 — 설계 결과는 사토장이 없어도 칸을 늘 싣는다. assert float(design.get("spoil_fill_area_m2") or 0) == 0, section["chainage_m"] assert float(design.get("spoil_fill_width_m") or 0) == 0, section["chainage_m"] assert design.get("spoil_fill_capacity_m3") in (None, 0), section["chainage_m"] # 노선 성토도 원래 값으로 돌아온다 — 사토장 몫을 빼 두었던 것이 복구된다. assert sections[2]["design"]["fill_area_m2"] == pytest.approx(plain_fill, abs=1e-6)