Files
Aislo/resources/tester/test_390_5_snap_step_ops.py
T
eomsangdonandClaude Opus 5.5 97b5a78695 fix(441 B05 스냅 끌기): 맞춤 교점 이동 한도 = 간격 · 미리보기 측점 밖 늘면 그 걸음 되돌림 · 초안 없는 측점 단계 = 저장 스냅
- StationFit reach = intervalM(반 간격이면 MC 18+9 ~ 19+1 못 맞춤)
- 끌기 · 방향키 — 미리보기 측점 밖 곡선 수가 누를 때보다 늘면 그 걸음 되돌림 · dragMoved 는 막힘 검사 뒤(433-14)
- 계산 줄 stations 단계 · follow 새 측점 — 초안 없으면 저장 노선 station_snap · station_snap_step(snapOptions)
- 시험 — 390_5 끌기 훑기(놓은 뒤 측점 밖 0 · 자동 · 고정 R) · 344_3 저장 스냅 · 266 사면 이동 한 걸음 · 다시 그림 한 번

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-18 18:42:06 +09:00

218 lines
9.1 KiB
Python

"""390-5 스냅 간격 = 조작 단위(요구 243) — 스냅 켬이면 방향키 한 번 = 고른 간격 · 끄는 동안 MC 가
간격 배수에 맞은 자리로 보임 · MC 측점 값 = 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 = 정수) ·
간격을 저장 노선(곡선표 `station_snap_step`)에 같이 → 서버 측점 셈(파이썬 짝)이 같은 값.
TS 셈은 `helper_390_5_snap_step_ops.cjs`(Node) · 파이썬 짝 거울 · 저장 왕복 · 편집기 이음은 여기.
./venv/Scripts/python.exe -m pytest resources/tester/test_390_5_snap_step_ops.py -q
"""
import json
import random
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap_step # noqa: E402
from B05_Profile.B05_Profile_Route_Save import RouteDraftInput, RouteSaveRequest # noqa: E402
from B05_Profile.B05_Profile_Router_Replan import _write_planned_polyline # noqa: E402
from common_util.common_util_route_geometry import planned_route_working_path # noqa: E402
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_390_5_snap_step_ops.cjs")
GRID = [float(v) for v in range(0, 1001, 20)]
STEPS = {"0.1": 0.1, "1": 1.0, "10": 10.0, "station": None}
def _spans() -> list[list[float]]:
rng = random.Random(3905)
spans, at = [], 15.0
while at < 950:
start = round(at + rng.uniform(0, 30), 3)
end = round(start + rng.uniform(18, 60), 3)
spans.append([start, end])
at = end + rng.uniform(5, 25)
return spans
SPANS = _spans()
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER), json.dumps({"grid": GRID, "spans": SPANS})],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
# ── ① 방향키 ──
def test_스냅_끔_방향키는_0_1_Shift_1(got) -> None:
assert got["keys"]["off"] == pytest.approx(0.1)
assert got["keys"]["offShift"] == pytest.approx(1.0)
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
def test_스냅_켬_방향키_한_번_고른_간격_Shift_무시(got, step) -> None:
key = got["keys"]["snap"][step]
assert key["plain"] == pytest.approx(float(step))
assert key["shift"] == pytest.approx(float(step))
# ── ② 끌기 미리보기 ──
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
def test_끄는_동안_MC_가_간격_배수(got, step) -> None:
drag = got["drag"][step]
assert drag["off"] == 0
assert max(drag["residues"]) <= drag["tol"]
assert drag["dragMoved"] <= float(step) + 1e-6 # 끈 IP 는 접선 따라 ±간격 안만(441)
@pytest.mark.parametrize("mode", ["auto", "fixed"])
def test_끌기_훑기_놓은_뒤_측점_밖_0(got, mode) -> None:
"""441 — 측점 No. 끌기(IP 셋 × 12 방향 × 3 거리) · 놓은 뒤 MC 가 다 측점 위 · 맞춤이 늘 닿음
(반 간격 한도면 18+9 ~ 19+1 에서 못 맞춰 막힘 걸음이 생김)."""
swept = got["swept"][mode]
assert swept["drags"] == 108 and swept["released"] > 0
assert swept["offAfter"] == 0, swept["worst"]
assert swept["strayed"] == 0
@pytest.mark.parametrize("step", ["0.1", "1", "10", "20"])
def test_미리보기는_쌓이지_않고_놓을_때와_같음(got, step) -> None:
drag = got["drag"][step]
assert drag["stale"] == 0
assert drag["release"] == 0
assert drag["before"] == 0 # 끈 IP 앞앞 IP 는 그대로
def test_끌기_방향키_편집기_이음() -> None:
pointer = (B05 / "B05_Profile_UI_RouteEdit_Pointer.ts").read_text(encoding="utf-8")
keys = (B05 / "B05_Profile_UI_RouteEdit_Keys.ts").read_text(encoding="utf-8")
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "params.fitDrag(dragNode)" in pointer
assert "params.fitDrag(now.index)" in keys and "params.snapStepM()" in keys
assert "fit.fitDrag(snapshotNow(), base, index, fitLimits())" in edit
# 하한 검사는 맞추기 전 노선으로(맞춤이 IP 를 ±간격/2 밀어 10 m · 측점 No. 에서 헛걸림)
assert "dragRaw.forEach((vertex, index) => (state.planned[index] = vertex))" in pointer
assert "now.raw.forEach((vertex, index) => (state.planned[index] = vertex))" in keys
# 미리보기 측점 밖이 누를 때보다 늘면 그 걸음 되돌림 · 막힘 검사 뒤에 옮김 표시(441 · 433-14)
assert "params.offStation() > dragOff" in pointer and "params.offStation() > now.off" in keys
move = pointer[pointer.index("const previous = state.planned[dragNode];") :]
assert move.index("shortfallCrossed(") < move.index("params.offStation() > dragOff")
assert move.index("params.offStation() > dragOff") < move.index("dragMoved = true;")
assert "offStation: () => (stationSnap ? fit.offStationCount(" in edit
assert edit.count("\n") <= 695
# ── ③ MC 측점 값 · 파이썬 짝 ──
def test_자릿수_배수(got) -> None:
assert got["scale"] == [10, 1, 1, 1]
assert [core._mc_scale(v) for v in (0.1, 1.0, 10.0, None)] == [10, 1, 1, 1]
@pytest.mark.parametrize("key", list(STEPS))
def test_MC_측점_값_자릿수(got, key) -> None:
values = got["snapped"][key]
moved = [v for v in values if v not in GRID]
assert moved, "호 안 측점이 MC 로 옮겨져야 함"
scale = 10 if key == "0.1" else 1
assert all(abs(v * scale - round(v * scale)) < 1e-9 for v in values)
if key == "0.1":
assert any(abs(v - round(v)) > 1e-9 for v in moved) # 소수 한 자리 MC 가 실제로 생김
@pytest.mark.parametrize("key", list(STEPS))
def test_파이썬_짝_거울(got, key) -> None:
py = [float(v) for v in core.snap_curve_stations(GRID, SPANS, STEPS[key])]
assert py == got["snapped"][key]
def test_옛_부르기는_정수_그대로() -> None:
old = [float(v) for v in core.snap_curve_stations(GRID, SPANS)]
assert old == [float(v) for v in core.snap_curve_stations(GRID, SPANS, None)]
assert all(v == round(v) for v in old)
def test_눈금_글_0_1_때만_소수_한_자리(got) -> None:
labels = got["labels"]
assert labels["int"] == "5+20" and labels["oneDefault"] == "5+20" # 다른 화면 = 정수(09-29)
assert labels["one"] == "5+19.8" and labels["oneSmall"] == "5+0.1"
assert labels["oneWhole"] == "6+0"
overlay = (ROOT / "B04_PreProcess" / "B04_PreProcess_UI_MapOverlays.ts").read_text(
encoding="utf-8"
)
assert "stationLabel(chainage, interval, options.chainages ? 1 : 0)" in overlay
# ── 저장 왕복 ──
def test_초안_간격이_요청으로_펴짐() -> None:
nodes = [(0.0, 0.0), (50.0, 10.0), (100.0, 0.0)]
draft = RouteDraftInput(nodes=nodes, stationSnap=True, stationSnapStep=0.1)
resolved = RouteSaveRequest(draft=draft).resolved()
assert resolved.station_snap is True and resolved.station_snap_step == 0.1
old = RouteSaveRequest(draft=RouteDraftInput(nodes=nodes, stationSnap=True))
assert old.resolved().station_snap_step is None
@pytest.mark.parametrize(
("snap", "step", "want"),
[
(True, 0.1, 0.1),
(True, 1.0, 1.0),
(True, 10.0, 10.0),
(True, None, None),
(False, 0.1, None),
],
)
def test_곡선표_저장_왕복(tmp_path, snap, step, want) -> None:
path = planned_route_working_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
points = [(0.0, 0.0), (120.0, 15.0), (240.0, -10.0), (360.0, 30.0)]
_write_planned_polyline(path, points, 30.0, station_snap=snap, station_snap_step=step)
assert load_station_snap_step(tmp_path) == want
def test_옛_곡선표는_측점_No(tmp_path) -> None:
path = planned_route_working_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("x,y\n0,0\n10,0\n", encoding="utf-8")
path.with_name(f"{path.stem}_curves.json").write_text(
json.dumps({"curves": [], "station_snap": True}), encoding="utf-8"
)
assert load_station_snap_step(tmp_path) is None
def test_서버_측점_셈이_저장_간격을_씀() -> None:
sections = (B05 / "B05_Profile_Engine_Sections.py").read_text(encoding="utf-8")
assert sections.count("station_snap_step=load_station_snap_step(project_root)") == 2
design = ROOT / "A00_Common" / "design"
page = (design / "design_pipeline_page.ts").read_text(encoding="utf-8")
assert "stationSnapStep: routePlan.station_snap_step" in page
stage = (design / "logic" / "design_pipeline_stations.ts").read_text(encoding="utf-8")
assert "...snapOptions(ctx.inputs.route, saved?.())" in stage # 초안 · 없으면 저장 노선(441)
assert "stationSnapStep: draft.stationSnapStep" in stage
assert "stationSnapStep: saved?.station_snap_step" in stage
assert "saved: () => plan" in page and "...snapOptions(inputs.route, plan)" in page
apply = (B05 / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
assert "stationSnap: true, stationSnapStep: null" in apply # 추천 = 측점 No.