Files
Aislo/resources/tester/test_194_1_berm_depth.py
T
eomsangdonandClaude Opus 5.5 cb7a140c10 feat(B06): 소단 = 좌측 깊이 칸 숨김 · 횡단도 소단 고름 ▲▼ 만 · 측점 · 쪽마다 깊이 patch · 고름 조각 키 · 좌측 소단 상자 켬(194-1 · 194-2)
- 정의 「상세 내용」 = 폭만 · depth_m 표시 끔(옛 구간 값 = 기본값으로 읽음 · 새 기본 1 m)
- 짝: BermSpec 쪽마다 깊이 · side_berm ↔ sideBerm · 설계 결과 berm_depth_{side}_m 되싣기(파이썬 ↔ TS)
- 저장: patch berm_depth_{side}_m(≥ 0) · 재계산 살림 목록 짝(RD ↔ CR) · 세션 bermdepth
- 서버 [저장] 다시 셈 = 구조물 정본 소단 구간(load_berm_spans) + 측점 깊이 · 구간 지우면 소단 없음
- 고름: 소단 수평 단 = 높이만(▲ 0 에서 멈춤 · ▼ 사면 끝 앞 멈춤 · ◀▶ 막힘) · 사면 = 가로 · 노면 = 종단
- 고름을 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각
- 새 시험 test_194_1_berm_depth + helper · test_178_2 · test_54_26 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 13:59:43 +09:00

340 lines
15 KiB
Python

"""194-1 · 194-2 소단 = 좌측 위치 칸 없앰 · 횡단도에서 고른 소단을 ▲▼ 로만 옮김.
측점 · 쪽마다 깊이(`berm_depth_{side}_m`).
- 좌측 칸 없음(정의 그룹 · 표시) · 기본 = 노면 끝 아래 1 m
- 짝 거울: `common_util_cross_berm` · 설계 기하(파이썬 ↔ TS) — 쪽마다 깊이(`berm_depth_{side}_m`)
- 서버 [저장] 다시 셈: 구조물 정본 소단 구간 + 측점 patch 깊이(옛 구간 depth_m = 기본값)
- 화면(흉내 DOM · `helper_194_1_berm_pick.cjs`): ▲▼ 만 · ◀▶ 막힘 · 도로 위 금지 ·
옮긴 뒤 같은 조각 ·
좌측 소단 상자 알림
"""
import json
import re
import subprocess
from pathlib import Path
import pytest
from pydantic import ValidationError
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
berm_from,
enforce_design_lines,
stored_berm,
)
from B06_Section.B06_Section_Schema import CrossSectionPatch
from common_util.common_util_cross_berm import BermSpec, side_berm
from config.config_system import STANDARD_CROSS_SECTION
ROOT = Path(__file__).resolve().parents[2]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
HELPER = Path(__file__).parent / "helper_194_1_berm_pick.cjs"
DEF = ROOT / "resources" / "master_template" / "구조물정의.json"
node_only = pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def _ground() -> list[dict]:
"""양성토 — 지반이 노면 끝보다 3 m 이상 낮음."""
return [
{"offset_m": o / 2, "elevation_m": round(96.0 + 0.02 * o / 2, 4), "valid": True}
for o in range(-40, 41)
]
def _berm(left=None, right=None, depth=1.0) -> BermSpec:
return BermSpec(0.5, 3.0, 0.0, depth, left, right)
CASES = [
{"name": "양쪽 기본 1.0", "berm": _berm()},
{"name": "좌 2.0 · 우 기본", "berm": _berm(left=2.0)},
{"name": "좌 0(노면 끝) · 우 0.5", "berm": _berm(left=0.0, right=0.5)},
{"name": "좌 음수 = 0", "berm": _berm(left=-1.0)},
]
def _python(berm: BermSpec) -> dict:
return compute_cross_design(
_ground(), 100.0, standard=STANDARD_CROSS_SECTION, ground_type="soil",
section_mode="both_fill", berm=berm,
) # fmt: skip
def _flat_z(design: dict, side: str) -> list[float]:
"""그 쪽 노면 끝 밖 평탄 도막(소단) 표고 — 지반(≤ 96.4)에 닿기 전 사면만."""
edge = design["road_edges"][side]["offset_m"]
sign = 1 if side == "left" else -1
line = sorted(
(
p
for p in design["design_line"]
if sign * p["offset_m"] >= sign * edge - 1e-6 and p["elevation_m"] > 96.5
),
key=lambda p: sign * p["offset_m"],
)
flats = {
round(a["elevation_m"], 3) # 설계선 표본이 한 단을 여러 도막으로 끊어도 한 표고
for a, b in zip(line, line[1:], strict=False)
if abs(b["offset_m"] - a["offset_m"]) > 1e-6
and abs(b["elevation_m"] - a["elevation_m"]) / abs(b["offset_m"] - a["offset_m"]) < 0.05
}
return sorted(flats)
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { computeCrossDesign } from "./common_util_cross_design.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const designs = input.cases.map((c) => computeCrossDesign(input.samples, 100, {
standard: input.standard, groundType: "soil", sectionMode: "both_fill",
berm: { widthM: c[0], intervalM: c[1], slopeDeg: c[2], depthM: c[3],
depthLeftM: c[4], depthRightM: c[5] },
}));
writeFileSync(process.argv[3], JSON.stringify(designs));
"""
@pytest.fixture(scope="module")
def ts_designs(tmp_path_factory) -> list[dict]:
tmp = tmp_path_factory.mktemp("berm194")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(ROOT / "common_util" / "common_util_cross_design.ts"),
"--outDir", str(out), "--module", "esnext", "--target", "es2022",
"--moduleResolution", "bundler", "--ignoreConfig"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
for emitted in out.glob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
src, dst = tmp / "in.json", tmp / "out.json"
src.write_text(
json.dumps(
{
"cases": [list(case["berm"]) for case in CASES],
"samples": _ground(),
"standard": STANDARD_CROSS_SECTION,
},
ensure_ascii=False,
),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
)
return json.loads(dst.read_text(encoding="utf-8"))
def _close(left, right, path=""):
if isinstance(left, dict):
assert set(left) == set(right), f"{path} 키 다름 {set(left) ^ set(right)}"
for key in left:
_close(left[key], right[key], f"{path}.{key}")
elif isinstance(left, list):
assert len(left) == len(right), f"{path} 길이"
for i, (a, b) in enumerate(zip(left, right, strict=True)):
_close(a, b, f"{path}[{i}]")
elif isinstance(left, (int, float)) and not isinstance(left, bool):
assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}"
else:
assert left == right, f"{path}: {left!r} vs {right!r}"
# ── 좌측 칸 없음 · 기본 1 m ────────────────────────────────────────────────
def test_좌측_패널_깊이_칸_없음() -> None:
data = json.loads(DEF.read_text(encoding="utf-8"))
assert data["그룹"]["berm"][0]["옵션"] == ["width_m"]
assert data["표시"]["berm"]["depth_m"] is False
berm = next(t for t in data["types"] if t["type_id"] == "berm")
depth = next(o for o in berm["options"] if o["key"] == "depth_m")
assert depth["default"] == 1.0 # 옛 구간 값 · 새 구간 기본 = 노면 끝 아래 1 m
def test_기본_1m_수평_한_단() -> None:
design = _python(_berm())
for side in ("left", "right"):
edge_z = design["road_edges"][side]["elevation_m"]
assert _flat_z(design, side) == [pytest.approx(edge_z - 1.0, abs=1e-3)]
assert "berm_depth_left_m" not in design and "berm_depth_right_m" not in design
# ── 짝 거울 · 쪽마다 깊이 ───────────────────────────────────────────────────
def test_쪽마다_깊이_그_쪽만() -> None:
by = {case["name"]: _python(case["berm"]) for case in CASES}
two = by["좌 2.0 · 우 기본"]
assert _flat_z(two, "left") == [pytest.approx(two["road_edges"]["left"]["elevation_m"] - 2.0)]
assert _flat_z(two, "right") == [pytest.approx(two["road_edges"]["right"]["elevation_m"] - 1.0)]
assert two["berm_depth_left_m"] == 2.0 and "berm_depth_right_m" not in two
assert two["berm"]["depth_m"] == 1.0 # 구간 값은 그대로
top = by["좌 0(노면 끝) · 우 0.5"]
assert _flat_z(top, "left") == [pytest.approx(top["road_edges"]["left"]["elevation_m"])]
neg = by["좌 음수 = 0"]
assert neg["berm_depth_left_m"] == 0.0 # 도로보다 높을 수 없음
assert neg["design_line"] == _python(_berm(left=0.0))["design_line"]
assert side_berm(_berm(right=1.5), "right").depth_m == 1.5
assert side_berm(_berm(right=1.5), "left").depth_m == 1.0
@node_only
def test_파이썬_TS_거울(ts_designs: list[dict]) -> None:
for case, ts in zip(CASES, ts_designs, strict=True):
_close(_python(case["berm"]), ts, case["name"])
def test_짝_머리_표시() -> None:
for name in ("common_util_cross_berm.py", "common_util_cross_berm.ts"):
text = (ROOT / "common_util" / name).read_text(encoding="utf-8")
assert "짝" in text and ("side_berm" in text or "sideBerm" in text)
# ── 측점 patch 저장 왕복 · 서버 다시 셈 ─────────────────────────────────────
def test_patch_칸_왕복() -> None:
patch = CrossSectionPatch(chainage_m=40, berm_depth_left_m=1.5, berm_depth_right_m=0)
dumped = {k: v for k, v in patch.model_dump().items() if v is not None}
assert dumped == {"chainage_m": 40, "berm_depth_left_m": 1.5, "berm_depth_right_m": 0}
with pytest.raises(ValidationError):
CrossSectionPatch(chainage_m=40, berm_depth_left_m=-0.1) # 도로 위 금지
assert {"berm_depth_left_m", "berm_depth_right_m"} <= set(USER_TOUCHED_KEYS)
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
assert '"berm_depth_left_m",' in refresh and '"berm_depth_right_m",' in refresh
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "patch.berm_depth_left_m = sides.left" in persist
def test_저장분_읽기_옛_구간값_기본() -> None:
old = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}})
assert (old.depth_m, old.depth_left_m, old.depth_right_m) == (2.5, None, None)
moved = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}, "berm_depth_left_m": 1.2})
assert (moved.depth_left_m, moved.depth_right_m) == (1.2, None)
# 프리뷰 payload(구간 제원 + 쪽 깊이)
spec = berm_from({"width_m": 0.5, "depth_m": 1.0, "berm_depth_right_m": -3}, {
"berm_depth_right_m": -3
}) # fmt: skip
assert spec.depth_right_m == 0.0
LONG = {
"design_profiles": [
{
"samples": [
{"chainage_m": 0, "elevation_m": 100},
{"chainage_m": 200, "elevation_m": 100},
]
}
]
}
def _project(tmp_path: Path, structures: list[dict]) -> Path:
route = tmp_path / "B05_Profile" / "route"
route.mkdir(parents=True)
(route / "structures.json").write_text(
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False),
encoding="utf-8",
)
return tmp_path
BERM_SPAN = {
"type_id": "berm",
"placement": "interval",
"start_m": 0,
"end_m": 100,
"options": {"width_m": 0.5, "depth_m": 2.5}, # 옛 좌측 칸 값 = 기본값
}
def test_저장_때_서버가_정본_구간_측점_깊이로_다시_셈(tmp_path: Path) -> None:
root = _project(tmp_path, [BERM_SPAN])
browser = compute_cross_design(_ground(), 100.0, ground_type="soil", section_mode="both_fill")
# 브라우저 patch = 사용자 값(좌 깊이)만 · 설계선은 소단 없는 옛 것
stored = {**browser, "berm_depth_left_m": 1.5}
sections = [{"chainage_m": 40.0, "samples": _ground(), "design": stored}]
assert enforce_design_lines(LONG, sections, root) == 1
design = sections[0]["design"]
assert design["berm"]["depth_m"] == 2.5 and design["berm_depth_left_m"] == 1.5
left_z = design["road_edges"]["left"]["elevation_m"]
right_z = design["road_edges"]["right"]["elevation_m"]
assert _flat_z(design, "left") == [pytest.approx(left_z - 1.5, abs=1e-3)]
assert _flat_z(design, "right") == [pytest.approx(right_z - 2.5, abs=1e-3)]
# 구간 밖 측점 = 소단 없음 · 옮긴 값만 남음(다시 놓으면 그대로 씀)
outside = [{"chainage_m": 150.0, "samples": _ground(), "design": dict(stored)}]
enforce_design_lines(LONG, outside, root)
assert "berm" not in outside[0]["design"] and _flat_z(outside[0]["design"], "left") == []
def test_구간_지우면_저장분_소단도_사라짐(tmp_path: Path) -> None:
root = _project(tmp_path, [])
old = _python(_berm(left=1.5))
sections = [{"chainage_m": 40.0, "samples": _ground(), "design": old}]
enforce_design_lines(LONG, sections, root)
assert "berm" not in sections[0]["design"]
assert _flat_z(sections[0]["design"], "left") == []
# ── 화면 고름(흉내 DOM) ─────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def ui() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
@node_only
def test_이동_셋_분리(ui: dict) -> None:
assert ui["axis"] == {"leftBerm": "depth", "rightBerm": "depth", "leftUpper": "x", "road": "y"}
assert "berm:left:0" in ui["keys"] and "fill:left:1" in ui["keys"]
@node_only
def test_소단_고름_위아래만(ui: dict) -> None:
berm = ui["berm"]
assert berm["title"] == "소단 높이 (좌)" and berm["readout"] == "도로 아래 1.0m"
assert berm["moveHidden"] is True and berm["liftHidden"] is False
assert berm["leftBlocked"] and berm["rightBlocked"] # ◀▶ 막힘
assert berm["afterSide"] == {"values": {"left": 0, "right": 0}, "sets": 0} # 가로 안 감
assert berm["upTaken"]
assert berm["sets"] == [["left", 0.9], ["left", 1.1], ["left", 1]] # ▲ · ▼ · ↺ 기본
assert berm["slopeTitle"] == "사면 이동 (좌)" and berm["slopeUpBlocked"]
assert berm["slopeSets"] == 0 and berm["roadSets"] == 0
assert berm["roadUpPassed"] # 노면 ▲▼ = 종단도 몫
@node_only
def test_도로_위_금지_사면_끝_앞_멈춤(ui: dict) -> None:
assert ui["limit"]["topFound"] and ui["limit"]["topSets"] == [] # 깊이 0 에서 ▲ = 안 감
assert ui["limit"]["deepSets"] == [["left", 2.8]] # ▼ 막힘 · ▲ 는 감
@node_only
def test_옮긴_뒤_같은_조각(ui: dict) -> None:
stable = ui["stable"]
assert stable["firstIndex"] != stable["againIndex"] # 번호는 바뀜
assert stable["restored"] == stable["againIndex"] # 키로 같은 소단
assert stable["upperRestored"] and stable["upperIndexMoved"]
@node_only
def test_좌측_소단_상자_알림_카드_창구(ui: dict) -> None:
assert ui["berm"]["focus"][0] is True and ui["berm"]["focusAfter"] is False
moves = ui["moves"]
assert moves["stationId"] == "S9" and moves["shiftValue"] == 0.3
chainage, change = moves["change"]
assert chainage == 40 and change["berm_depth"] == {"side": "left", "valueM": 1.3}
assert change["ground_type"] == "soil" # 카드 버튼 값은 그대로 실음
assert moves["host"] == [40, None] # 한 번만 켬 · 다른 측점 끔은 무시
assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 패널 없음
selection = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Selection.ts").read_text(
encoding="utf-8"
)
assert "setBermPickHost(" in selection and 'classList.add("is-active")' in selection