Files
Aislo/resources/tester/test_b05_min_cover_guard.py
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

150 lines
6.7 KiB
Python

# -*- 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"
)
PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.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)