# -*- coding: utf-8 -*- """종단 편집 최소고 가드 개편(2026-08-23) 검증. 기존 가드는 측점 ▼에만 걸리고 관경 고정 산식(지반+관경+토피)이라 ① 구간 쉬프트가 횡단배수 앵커를 최소고 아래로 끌어내렸고 ② BOX암거·세월교를 과소, 물넘이포장을 과잉 차단했다. 개편: 편집 후보로 정렬을 계산해 시설별 최소고(minCoverPoints 원천) 위반이 **새로 생기거나 커지면** 차단 — 측점·구간·곡선 경유 하강을 한 가드로 잡는다. """ import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) RENDER = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Render.ts").read_text( encoding="utf-8" ) # 종단 패널 본체는 종단도 한 벌로 옮겼다(계획서 24-2c) — 편집 한 길도 그 안에 있다. PANEL = (PROJECT_ROOT / "A00_Common" / "design" / "design_profile.ts").read_text( encoding="utf-8" ) GUARD = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_MinCover.ts").read_text( encoding="utf-8" ) def _violations(targets, ground_at, plan_at, tolerance=0.001): """TS findMinCoverViolations와 같은 규칙.""" result = [] for chainage, clearance in targets: required = ground_at(chainage) + clearance planned = plan_at(chainage) if planned < required - tolerance: result.append((chainage, required - planned)) return result def _blocks(targets, ground_at, plan_now, plan_next): """TS blocksMinCover와 같은 규칙 — 위반이 새로 생기거나 커지면 True.""" planned = _violations(targets, ground_at, plan_next) if not planned: return False current = dict(_violations(targets, ground_at, plan_now)) return any(short > current.get(chainage, 0) + 1e-6 for chainage, short in planned) TARGETS = [(40.0, 2.5)] # BOX암거 H2.0 → 지반 +2.5 GROUND = lambda c: 100.0 # noqa: E731 def test_blocks_new_violation(): """최소고 위(102.6)에서 아래(102.4)로 내리는 편집은 차단된다.""" assert _blocks(TARGETS, GROUND, lambda c: 102.6, lambda c: 102.4) def test_allows_edit_down_to_exact_minimum(): """정확히 최소고(102.5)까지는 허용 — 한계에 앉히는 편집을 막지 않는다.""" assert not _blocks(TARGETS, GROUND, lambda c: 102.6, lambda c: 102.5) def test_allows_recovery_when_already_violating(): """이미 위반(102.0)이면 악화가 아닌 한 허용 — 복구(올림) 편집을 막으면 안 된다.""" assert not _blocks(TARGETS, GROUND, lambda c: 102.0, lambda c: 102.3) assert _blocks(TARGETS, GROUND, lambda c: 102.0, lambda c: 101.9) # 악화는 차단. def test_unlisted_station_is_free(): """대상 목록에 없는 자리(물넘이포장 등)는 어떤 편집도 막지 않는다.""" assert not _blocks([], GROUND, lambda c: 102.6, lambda c: 90.0) def test_source_guard_sits_on_the_single_apply_path(): """가드가 **모든 편집이 지나는 한 곳**(`applyEdits`)에 걸려 있는지 소스 검사. 2026-09-02: 측점 끌기(`_Profile_Render`)에만 걸려 있어 [직선화]·[쉬프트]·틸팅· 방향키가 그냥 지나갔다. 판정 로직을 `_Profile_MinCover.blocksMinCover` 로 옮기고 `_Profile_Panel.applyEdits` 한 곳에서만 부른다 — 화면 쪽에는 사본을 두지 않는다. """ assert ( "if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;" in PANEL ) assert PANEL.count("blocksMinCover(") == 1 # 부르는 자리는 한 곳뿐. assert "export function blocksMinCover(" in GUARD assert "blocksMinCover" not in RENDER # 화면 모듈에 사본이 남지 않았다. # 판정점 = 제어점 z(라운드 중심) — 이웃 틸팅이 잠기지 않는다(2026-08-23). assert GUARD.count("controlElevationAt(") >= 2 assert "planElevationAt(candidate" not in GUARD def test_source_old_diameter_formula_removed(): """관경 고정 산식·배관 판정이 사라지고 시설별 산식(minCoverPoints 원천)을 쓴다.""" assert "blocksPipeLowering" not in RENDER assert "MIN_PIPE_COVER_M" not in RENDER assert "diameterMm ?? 1000" not in RENDER assert "findMinCoverViolations" in GUARD assert "minCoverTargets: () => minCoverTargets" in PANEL ALIGNMENT = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts").read_text( encoding="utf-8" ) def test_shift_moves_hinges_not_fixed_ends(): """쉬프트 재정의(2026-08-23 사용자 개념 확정) 소스 잠금: 고정점(BP·EP·배수 앵커)은 안 움직이고 안쪽 미틸트 측점 외곽 2개가 힌지로 승격(라운드 생성)된다. 힌지 부족이면 쉬프트 불가(버튼 숨김).""" assert "export function shiftMovingPoints" in ALIGNMENT assert "export function controlElevationAt" in ALIGNMENT body = ALIGNMENT[ALIGNMENT.index("export function shiftSegment") :] assert "shiftMovingPoints(current, segment)" in body assert "if (!moving) return edits;" in body assert "FEEDBACK_PASSES" in body # 라운드(중앙종거) 낀 자리도 1클릭 = 정확 delta. EDIT = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Edit.ts").read_text( encoding="utf-8" ) # 구간 쉬프트 버튼(⬆⬇)과 그 노출 조건(canShift)은 2026-09-02 지시로 삭제됐다. # 기하 함수는 남겨 두기로 했으므로(PLAN.md) 위 존재 검사는 그대로 두고 배선만 잠근다. assert "canShift" not in EDIT assert "canShift:" not in RENDER def _moving_points(stations, edited, seg_from, seg_to): """TS shiftMovingPoints와 같은 규칙 — 끝점이 틸팅점이면 그 점, 아니면 안쪽 외곽 측점.""" interior = [s for s in stations if seg_from + 1e-6 < s < seg_to - 1e-6] left = seg_from if seg_from in edited else (interior[0] if interior else None) right = seg_to if seg_to in edited else (interior[-1] if interior else None) if left is None or right is None or right - left < 1e-6: return None return (left, right) def test_moving_points_promote_interior_hinges(): """앵커-앵커 구간: 안쪽 미틸트 측점 외곽 2개(첫·끝)가 힌지가 된다.""" assert _moving_points([20, 40, 60, 80], set(), 0, 84.3) == (20, 80) def test_moving_points_need_two_hinges(): """안쪽 측점이 1개 이하면 힌지 둘을 못 만든다 — 쉬프트 불가.""" assert _moving_points([20], set(), 0, 30) is None assert _moving_points([], set(), 0, 30) is None def test_moving_points_reuse_tilted_ends(): """사용자가 틸팅한 끝점은 직접 움직인다 — 같은 구간 반복 쉬프트 경로.""" assert _moving_points([40, 60], {20, 80}, 20, 80) == (20, 80) assert _moving_points([40, 60], {80}, 20, 80) == (40, 80)