Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/* 383-2 교각점 방향키 — `logic/B05_Profile_RouteEdit_KeyNudge.ts` 를 Node 로 돌려 JSON 한 줄을 냄
|
||||
* (`test_383_2_route_keys.py` 가 판정). `.cjs` = 루트 package.json 이 "type": "module" 이라서.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) =>
|
||||
request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const nudge = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_RouteEdit_KeyNudge.ts"));
|
||||
|
||||
// 화면 → 도면: 1 px = 0.5 m · 화면 y 아래 = 도면 y 위로 뒤집힘 · 지도 30° 돌림
|
||||
const turn = (Math.PI / 180) * 30;
|
||||
const plain = (x, y) => [x * 0.5, -y * 0.5];
|
||||
const rotated = (x, y) => {
|
||||
const [mx, my] = plain(x, y);
|
||||
return [mx * Math.cos(turn) - my * Math.sin(turn), mx * Math.sin(turn) + my * Math.cos(turn)];
|
||||
};
|
||||
const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
|
||||
const steps = (metricAt, shift) =>
|
||||
Object.fromEntries(keys.map((key) => [key, nudge.arrowStep(key, shift, metricAt)]));
|
||||
|
||||
const before = [
|
||||
[0, 0],
|
||||
[50, 0],
|
||||
[100, 20],
|
||||
[150, 0],
|
||||
];
|
||||
const snapped = [
|
||||
[0, 0],
|
||||
[50.8, 0],
|
||||
[100, 20.6],
|
||||
[150, 0],
|
||||
];
|
||||
const stayed = [
|
||||
[0, 0],
|
||||
[50.02, 0],
|
||||
[100, 20],
|
||||
[150, 0],
|
||||
];
|
||||
const merged = [
|
||||
[0, 0],
|
||||
[50.4, 0],
|
||||
[150, 0],
|
||||
];
|
||||
const out = {
|
||||
plain: steps(plain, false),
|
||||
shift: steps(plain, true),
|
||||
rotated: steps(rotated, false),
|
||||
arrows: keys.map(nudge.isArrowKey).concat(nudge.isArrowKey("Enter")),
|
||||
snapped: nudge.nudgeReport(before, snapped, 1, [50, 0], [50.3, 0]),
|
||||
stayed: nudge.nudgeReport(before, stayed, 1, [50, 0], [50.3, 0]),
|
||||
merged: nudge.nudgeReport(before, merged, 1, [50, 0], [50.3, 0]),
|
||||
messages: {
|
||||
snapped: nudge.nudgeMessage({ movedM: 0.8, pushed: 1 }, true),
|
||||
stayed: nudge.nudgeMessage({ movedM: 0.02, pushed: 0 }, true),
|
||||
free: nudge.nudgeMessage({ movedM: 0.3, pushed: 0 }, false),
|
||||
},
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -40,9 +40,9 @@ def runs() -> dict:
|
||||
caught: dict = {}
|
||||
real = engine.build_pool
|
||||
|
||||
def spy(graph, follow, max_grade, min_radius_m, measure):
|
||||
def spy(graph, follow, max_grade, min_radius_m, measure, snap):
|
||||
caught["measure"] = measure
|
||||
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure)
|
||||
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap)
|
||||
return caught["pool"]
|
||||
|
||||
engine._POOLS.clear()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""383-1 R = 일반측점 기본 제약 — 새 초기 노선 맞춤 + [측점 스냅] 켬 · 추천 후보 맞춤 · [적용] 켬.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_383_1_route_station_default.py -q
|
||||
|
||||
새 사본(예상노선만) → 초기 노선 곡선 MC 가 일반측점 위(≈ 0 밖) · 곡선표 `station_snap` 켬 ·
|
||||
추천 「예상노선 추적」(켬) 노드 = 초기 노선 노드 · 측점 밖 곡선 = 「못 지킨 구간」(station) ·
|
||||
옛 사본(초기 노선 이미 있음)은 파일 · 깃발 그대로(말없이 측점 안 바뀜) · 추천 [적용] 은 스냅 켬.
|
||||
첫 셈(검증_sub1 · 14 % · R12 · 묶음 + 영선) 74.6 s → 42.6 s(10-17 실측 · 같은 기계).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from shapely.geometry import LineString, Point
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import generate_initial_route
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import MC_TOL_M, STATION_M
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
|
||||
from B05_Profile.B05_Profile_Router_Replan import (
|
||||
_curves_path,
|
||||
_ensure_planned_initial,
|
||||
_nodes_path,
|
||||
_write_planned_polyline,
|
||||
)
|
||||
from common_util.common_util_route_geometry import (
|
||||
expected_route_csv_path,
|
||||
planned_route_initial_path,
|
||||
read_planned_route_csv,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PROJECT = ROOT / "storage/1/3/7e420ed6-f940-4d12-a1a5-39cdcf70a391" # 검증_sub1
|
||||
RADIUS = 12.0
|
||||
|
||||
|
||||
def _copy_expected(tmp_path: Path) -> Path:
|
||||
source = expected_route_csv_path(PROJECT)
|
||||
if not source.is_file():
|
||||
pytest.skip("검증_sub1 storage 없음")
|
||||
target = expected_route_csv_path(tmp_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _off_station(path: Path) -> tuple[int, int]:
|
||||
"""(측점 밖 곡선 수, 곡선 수) — 저장 파일에서 다시 잼(MC = BC · EC 누가거리 가운데)."""
|
||||
line = LineString([(v.x, v.y) for v in read_planned_route_csv(path).vertices])
|
||||
curves = json.loads(_curves_path(path).read_text(encoding="utf-8"))["curves"]
|
||||
taken, off = set(), 0
|
||||
for curve in curves:
|
||||
mc = (line.project(Point(curve["start"])) + line.project(Point(curve["end"]))) / 2
|
||||
n = round(mc / STATION_M)
|
||||
if n >= 1 and abs(mc - n * STATION_M) <= MC_TOL_M and n not in taken:
|
||||
taken.add(n)
|
||||
else:
|
||||
off += 1
|
||||
return off, len(curves)
|
||||
|
||||
|
||||
def test_new_copy_initial_on_stations_and_flag_on(tmp_path: Path) -> None:
|
||||
root = _copy_expected(tmp_path)
|
||||
assert _ensure_planned_initial(root, RADIUS) is not None
|
||||
initial = planned_route_initial_path(root)
|
||||
off, total = _off_station(initial)
|
||||
print(f"새 초기 노선 곡선 {total} · 측점 밖 {off}")
|
||||
assert total and off <= 1
|
||||
assert load_station_snap(root) is True
|
||||
|
||||
|
||||
def test_follow_nodes_equal_initial(tmp_path: Path) -> None:
|
||||
"""추천 「예상노선 추적」(측점 맞춤 켬) = 새 초기 노선 · 측점 밖 곡선 수 = station 목록 수."""
|
||||
surface = PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz"
|
||||
if not surface.is_file():
|
||||
pytest.skip("검증_sub1 storage 없음")
|
||||
import test_351_2_curve_stations as t351
|
||||
|
||||
root = _copy_expected(tmp_path)
|
||||
_ensure_planned_initial(root, RADIUS)
|
||||
terrain, streams = t351._project_terrain()
|
||||
expected = [(v.x, v.y) for v in read_planned_route_csv(expected_route_csv_path(root)).vertices]
|
||||
result = generate_initial_route(
|
||||
expected,
|
||||
terrain,
|
||||
streams,
|
||||
mode="follow",
|
||||
weights=None,
|
||||
max_grade=0.14,
|
||||
min_radius_m=RADIUS,
|
||||
with_profile=False,
|
||||
)
|
||||
saved = [
|
||||
(v.x, v.y)
|
||||
for v in read_planned_route_csv(_nodes_path(planned_route_initial_path(root))).vertices
|
||||
]
|
||||
nodes = [(node["x"], node["y"]) for node in result["nodes"]]
|
||||
assert len(nodes) == len(saved)
|
||||
assert all(abs(a[0] - b[0]) <= 1e-3 and abs(a[1] - b[1]) <= 1e-3 for a, b in zip(nodes, saved))
|
||||
misses = [v for v in result["metrics"]["violations"] if v["kind"] == "station"]
|
||||
assert len(misses) == result["metrics"]["over_limit"]["station"] == result["off_station_curves"]
|
||||
assert result["unsnapped"] is not None # 「측점 맞춤」 끔 = 맞추기 전 선
|
||||
|
||||
|
||||
def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None:
|
||||
"""옛 사본 — 초기 노선(깃발 칸 없음)이 이미 있으면 다시 안 씀 · 스냅 끔 그대로."""
|
||||
root = _copy_expected(tmp_path)
|
||||
initial = planned_route_initial_path(root)
|
||||
points = [(v.x, v.y) for v in read_planned_route_csv(expected_route_csv_path(root)).vertices]
|
||||
_write_planned_polyline(initial, points, RADIUS) # 옛 규칙(맞춤 없음 · 칸 없음)
|
||||
before = {p: p.read_bytes() for p in (initial, _curves_path(initial), _nodes_path(initial))}
|
||||
assert _ensure_planned_initial(root, RADIUS) is None
|
||||
assert {p: p.read_bytes() for p in before} == before
|
||||
assert load_station_snap(root) is False
|
||||
|
||||
|
||||
def test_apply_saves_station_snap_on() -> None:
|
||||
text = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
|
||||
assert "routeDraftOf(nodes, { ...settings, stationSnap: true })" in text
|
||||
@@ -0,0 +1,80 @@
|
||||
"""383-2 교각점 방향키 — `logic/B05_Profile_RouteEdit_KeyNudge.ts` 를
|
||||
Node(`helper_383_2_key_nudge.cjs`)로 돌려 판정(화면 축 0.1 m · Shift ×10 · 돌린 지도 · 스냅 뒤
|
||||
실제 이동 · 함께 밀린 IP · 제자리 안내) + 이음(`_RouteEdit_Keys` · 끌기 걸기 한 줄 · 평면이
|
||||
삼킴 → 종단 ▲▼ 비킴).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_383_2_route_keys.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
HELPER = Path(__file__).with_name("helper_383_2_key_nudge.cjs")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (B05 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_화면_축_0_1m_Shift_10배(got) -> None:
|
||||
plain = got["plain"]
|
||||
assert plain["ArrowUp"] == pytest.approx([0, 0.1]) # 화면 위 = 도면 북쪽(뒤집힌 y)
|
||||
assert plain["ArrowDown"] == pytest.approx([0, -0.1])
|
||||
assert plain["ArrowLeft"] == pytest.approx([-0.1, 0])
|
||||
assert plain["ArrowRight"] == pytest.approx([0.1, 0])
|
||||
assert got["shift"]["ArrowRight"] == pytest.approx([1.0, 0])
|
||||
assert got["arrows"] == [True, True, True, True, False]
|
||||
|
||||
|
||||
def test_돌린_지도도_화면_위(got) -> None:
|
||||
up = got["rotated"]["ArrowUp"]
|
||||
turn = math.radians(30)
|
||||
assert math.hypot(*up) == pytest.approx(0.1)
|
||||
assert up == pytest.approx([-0.1 * math.sin(turn), 0.1 * math.cos(turn)])
|
||||
|
||||
|
||||
def test_스냅_뒤_실제_이동_함께_밀린_IP(got) -> None:
|
||||
assert got["snapped"]["movedM"] == pytest.approx(0.8)
|
||||
assert got["snapped"]["pushed"] == 1
|
||||
assert got["stayed"]["movedM"] == pytest.approx(0.02) and got["stayed"]["pushed"] == 0
|
||||
assert got["merged"]["movedM"] == pytest.approx(0.4) and got["merged"]["pushed"] == 0
|
||||
messages = got["messages"]
|
||||
assert "실제 이동 0.8 m" in messages["snapped"] and "함께 밀린 IP 1곳" in messages["snapped"]
|
||||
assert "같은 측점 — 스냅 끄고 미세 조정" in messages["stayed"]
|
||||
assert "0.3 m 옮겼습니다" in messages["free"]
|
||||
|
||||
|
||||
def test_이음_끌기_걸기_한_줄_창_가로채기() -> None:
|
||||
pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
|
||||
assert pointer.count("bindRouteEditKeys(") == 1
|
||||
keys = _read("B05_Profile_UI_RouteEdit_Keys.ts")
|
||||
assert 'window.addEventListener("keydown", onKeyDown, true)' in keys # 앞서 받음(capture)
|
||||
assert 'lastPointerWithin(".b05-routeedit")' in keys
|
||||
assert "event.preventDefault();\n event.stopPropagation();" in keys
|
||||
assert "state.apexLock[index] === true" in keys # 고정 IP 는 안 옮김
|
||||
assert "shortfallCrossed(" in keys # 하한 검사 = 끌기와 같음
|
||||
assert "params.commit();" in keys # 뗄 때 한 걸음(스냅이면 그때 맞춤)
|
||||
# 종단 ▲▼ 는 앞선 preventDefault 를 보고 비킴
|
||||
tools = _read("B05_Profile_UI_Profile_Panel_Tools.ts")
|
||||
assert "if (event.defaultPrevented) return;" in tools
|
||||
base = (ROOT / "B06_Section/B06_Section_UI_Cross_Panel_Base.ts").read_text(encoding="utf-8")
|
||||
assert "export function lastPointerWithin(selector: string)" in base
|
||||
|
||||
|
||||
def test_RouteEdit_본문_줄_안_늘림() -> None:
|
||||
assert len(_read("B05_Profile_UI_RouteEdit.ts").splitlines()) <= 700
|
||||
@@ -0,0 +1,20 @@
|
||||
"""383-3 노선 추천 모달 [재계산] — 측점 맞춤 오른쪽 같은 줄 · 늘 보임 · 바꾼 것 있을 때만 켜짐.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_383_3_rerun_row.py -q
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
|
||||
|
||||
def test_재계산_한_줄_늘_보임_바꿨을_때만_켜짐() -> None:
|
||||
ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
|
||||
assert re.search(r'<div class="b05-routerec__row"><button[^>]*data-act="rerun">재계산', ts)
|
||||
assert 'data-act="rerun" hidden' not in ts and "rerun.hidden" not in ts
|
||||
assert "rerun.before(snapToggle)" in ts # 토글이 같은 줄 왼쪽
|
||||
assert "rerun.disabled = busy || !dirty || invalid.size > 0;" in ts
|
||||
css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8")
|
||||
assert re.search(r"\.b05-routerec__row \{\s*display: flex;", css)
|
||||
@@ -120,19 +120,17 @@ def _earth(result: dict) -> float:
|
||||
return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"]
|
||||
|
||||
|
||||
def _raw(result: dict) -> dict:
|
||||
"""측점 맞추기 전 선(351-2) — 갈래 고르기 · `similar` 는 이 선으로 함."""
|
||||
return {**result, **(result.get("unsnapped") or {})}
|
||||
|
||||
|
||||
def _check_branch_goals(runs: dict) -> None:
|
||||
"""갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 ·
|
||||
계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5).
|
||||
고르기는 측점 맞추기 전 선으로 함(351-2)."""
|
||||
runs = {mode: _raw(result) for mode, result in runs.items()}
|
||||
고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1)."""
|
||||
branches = [runs[mode]["metrics"] for mode in BRANCHES]
|
||||
|
||||
def rank(goal, m: dict) -> tuple:
|
||||
return (m["over_limit"].get("station", 0) > 0, *goal(m))
|
||||
|
||||
for mode, goal in GOALS.items():
|
||||
assert all(goal(runs[mode]["metrics"]) <= goal(m) for m in branches), mode
|
||||
assert all(rank(goal, runs[mode]["metrics"]) <= rank(goal, m) for m in branches), mode
|
||||
for result in runs.values():
|
||||
m = result["metrics"]
|
||||
assert abs(m["surplus_m3"] - (m["cut_m3"] - m["fill_m3"])) <= 0.11
|
||||
@@ -242,7 +240,10 @@ def test_terrain_impossible_is_listed() -> None:
|
||||
assert result["relaxed"] is True
|
||||
assert result["terrain_bound_pct"] > GRADE * 100.0
|
||||
grades = [v for v in result["metrics"]["violations"] if v["kind"] == "grade"]
|
||||
assert grades and all("지형상 불가능" in v["reason"] for v in grades)
|
||||
# 측점 밖 0 후보 먼저(383-1) — 쓸기 후보(비중 계산 사유)가 뽑힐 수 있음
|
||||
assert grades and all(
|
||||
("지형상 불가능" in v["reason"]) or ("비중 계산" in v["reason"]) for v in grades
|
||||
)
|
||||
assert all(v["station"].startswith("No.") for v in grades)
|
||||
|
||||
|
||||
@@ -454,7 +455,7 @@ def test_project_similar_and_band(project_runs: dict) -> None:
|
||||
earthwork = project_runs["earthwork"]
|
||||
assert earthwork["band_width_m"] < 20.0
|
||||
near = {item["logic"]: item["max_gap_m"] for item in earthwork["similar"]}
|
||||
assert near["gentle"] == round(_gap(_raw(earthwork), _raw(project_runs["gentle"])), 1)
|
||||
assert near["gentle"] == round(_gap(earthwork, project_runs["gentle"]), 1)
|
||||
assert all(gap < 10.0 for gap in near.values()) and "earthwork" not in near
|
||||
assert "follow" not in near # 추적은 40 m 넘게 떨어짐
|
||||
|
||||
|
||||
Reference in New Issue
Block a user