- ↶ ↷ 글 없이 기호 + aria-label · title(단축키) - 저장 전으로 단추 · history.reset · 초안 다리 reset 지움 - 시험 chrome_modal · 346_4 · 346_1 고쳐 씀 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
200 lines
8.0 KiB
Python
200 lines
8.0 KiB
Python
"""PLAN 346-1 — 계산 묶음을 B05 페이지 · 노선 편집에 붙임.
|
|
|
|
· 실제 단계 한 번(route · follow · stations · ground · planline · profile · footprint) — 노선 한 점 옮김
|
|
뒤 종단까지 0.3 s 안 · 바뀐 구간 앞 첫 계획선 꼭짓점 · 측점 열쇠 그대로 · 계획고 한 점은 측점 · 지반 안 셈
|
|
· 늦은 응답 버림(격자 없음 · 서버 길 지반이 늦는 사이 노선을 또 옮김)
|
|
· 편집기 다리(사진첩 감싸기 · 열 때와 같으면 null · 초안 되살림)
|
|
· 페이지 묶음 — 노선 초안 캐시 칸 · 종단도가 받음 · 떠났다 다시 열어도 초안 살아남음 · ▲▼ = grade
|
|
짝: `helper_346_1_pipeline_page.cjs`(실제 TS 를 Node 로 돌림 · 서버 창구만 가짜).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
ROOT = HERE.parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
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_Grade import resolve_grade_options # noqa: E402
|
|
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
|
design_ground_following_profile,
|
|
)
|
|
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
|
|
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
|
|
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
|
|
reason="프론트엔드 의존성 미설치",
|
|
)
|
|
|
|
PROJECT = ROOT / "storage/1/3/1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a"
|
|
REAL_GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz"
|
|
REAL_NODES = PROJECT / "B05_Profile/route/planned_route_initial_nodes.csv"
|
|
MIN_RADIUS_M = 12.0
|
|
BUDGET_MS = 300.0
|
|
|
|
|
|
def _types() -> list[dict]:
|
|
return [
|
|
{
|
|
"type_id": t.type_id,
|
|
"group": t.group,
|
|
"placement": t.placement,
|
|
"managed_by": t.managed_by,
|
|
}
|
|
for t in structure_type_map().values()
|
|
]
|
|
|
|
|
|
def _line(nodes: list[list[float]]) -> list[list[float]]:
|
|
built = build_planned_polyline(
|
|
[tuple(n) for n in nodes],
|
|
min_radius_m=MIN_RADIUS_M,
|
|
simplify=False,
|
|
curve_flags=[True] * len(nodes),
|
|
radii=[None] * len(nodes),
|
|
)
|
|
return [list(v) for v in built.vertices]
|
|
|
|
|
|
def _fake(folder: Path) -> tuple[Path, list[list[float]]]:
|
|
"""가짜 격자 — 칸 폭 고르지 않음 · 빈칸 띠."""
|
|
rng = np.random.default_rng(3461)
|
|
x = (np.cumsum(rng.uniform(0.95, 1.1, 260)) + 1000.0).astype(np.float32)
|
|
y = (np.cumsum(rng.uniform(0.95, 1.1, 160)) + 2000.0).astype(np.float32)
|
|
xx, yy = np.meshgrid(x.astype(np.float64), y.astype(np.float64))
|
|
z = 500 + 0.08 * (xx - 1000) + 0.3 * (yy - 2000) + 2 * np.sin(xx / 13.0)
|
|
mask = np.ones(z.shape, dtype=bool)
|
|
mask[60:70, 100:115] = False
|
|
npz = folder / "dtm_fake.npz"
|
|
np.savez(npz, x=x, y=y, z=z.astype(np.float32), valid_mask=mask)
|
|
return npz, [[1010.0, 2020.0], [1090.0, 2040.0], [1150.0, 2110.0], [1250.0, 2135.0]]
|
|
|
|
|
|
def _real(_folder: Path) -> tuple[Path, list[list[float]]]:
|
|
if not (REAL_GRID.is_file() and REAL_NODES.is_file()):
|
|
pytest.skip("검증_sub8 없음")
|
|
with REAL_NODES.open(encoding="utf-8") as handle:
|
|
return REAL_GRID, [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)]
|
|
|
|
|
|
def _run(case) -> dict:
|
|
with tempfile.TemporaryDirectory() as folder:
|
|
npz, nodes = case(Path(folder))
|
|
line = _line(nodes)
|
|
longitudinal = core.generate_sections(
|
|
np.asarray(line, dtype=np.float64), DtmGridSampler.from_npz(npz)
|
|
)["longitudinal"]
|
|
options = resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None)
|
|
policy = design_ground_following_profile(longitudinal, options)[0]["policy"]
|
|
job = {
|
|
"nodes": nodes,
|
|
"line": line,
|
|
"minRadius": MIN_RADIUS_M,
|
|
"policy": policy,
|
|
"types": _types(),
|
|
}
|
|
path = Path(folder) / "job.json"
|
|
path.write_text(json.dumps(job), encoding="utf-8")
|
|
grid = Path(folder) / "grid.bin"
|
|
grid.write_bytes(dtm_grid_bytes(npz))
|
|
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
|
["node", str(HERE / "helper_346_1_pipeline_page.cjs"), str(path), str(grid)],
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
timeout=120,
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
@pytest.fixture(scope="module", params=[_fake, _real], ids=["가짜", "검증_sub8"])
|
|
def r(request) -> dict:
|
|
return _run(request.param)
|
|
|
|
|
|
def test_실제_단계_한_번_노선_옮김_종단까지_시간(r):
|
|
real = r["real"]
|
|
assert real["first"]["outcome"] == "done" and real["savedPlanline"] is True
|
|
run = real["routeRun"]
|
|
assert run["outcome"] == "done"
|
|
for stage in ("route", "follow", "stations", "ground", "planline", "profile"):
|
|
assert stage in run["stages"], stage
|
|
to_profile = sum(
|
|
run["stages"][s] for s in ("route", "follow", "stations", "ground", "planline", "profile")
|
|
)
|
|
assert to_profile < BUDGET_MS, run["stages"]
|
|
assert run["totalMs"] < BUDGET_MS
|
|
assert real["moved"]["profileStations"] > 2 and real["moved"]["footprint"] is True
|
|
|
|
|
|
def test_바뀐_구간_앞은_그대로(r):
|
|
real = r["real"]
|
|
from_m = real["range"]["fromM"]
|
|
assert 0 < from_m and real["range"]["toM"] is None # JSON 의 Infinity = null
|
|
saved = [p for p in real["savedBasePvi"] if p["chainage_m"] < from_m - 1e-6]
|
|
moved = [p for p in real["moved"]["basePvi"] if p["chainage_m"] < from_m - 1e-6]
|
|
assert saved and moved == saved
|
|
before = {k: c for k, c in real["beforeKeys"] if c < from_m - 20}
|
|
after = dict(real["moved"]["stations"])
|
|
assert before and all(after.get(k) == c for k, c in before.items())
|
|
|
|
|
|
def test_계획고_한_점은_측점_지반을_안_셈(r):
|
|
real = r["real"]
|
|
g = real["gradeRun"]
|
|
assert g["outcome"] == "done"
|
|
assert {"route", "follow", "stations", "ground", "planline"} <= set(g["skipped"])
|
|
assert "profile" in g["stages"]
|
|
rng = real["gradeRange"]
|
|
assert rng["fromM"] < real["target"] < (rng["toM"] if rng["toM"] is not None else float("inf"))
|
|
assert abs(real["raised"] - 0.8) < 1e-6
|
|
|
|
|
|
def test_늦은_응답_버림(r):
|
|
late = r["late"]
|
|
assert late["a"] == "dropped" and late["b"] == "done"
|
|
assert late["profileGen"] == ["profile@2"]
|
|
assert not [c for c in late["commits"] if c.startswith(("ground@1", "planline@1"))]
|
|
|
|
|
|
def test_편집기_다리(r):
|
|
d = r["draftLink"]
|
|
assert d["told"] == [3, 2] # 쌓기 · 되돌리기 — 다시하기 없음은 안 알림
|
|
assert d["restored"] == [
|
|
[r["n"], True]
|
|
] # 저장 안 한 초안 = 새 걸음으로 되살림 · 없으면 안 부름
|
|
assert d["unwatched"] is True
|
|
|
|
|
|
def test_페이지_묶음_노선_초안_캐시_칸(r):
|
|
p = r["page"]
|
|
assert p["before"] == {"saved": None, "pipe": True}
|
|
assert (
|
|
p["cached"]["rev"] == 1
|
|
and p["cached"]["terrain"] == "las"
|
|
and p["cached"]["nodes"] == r["n"]
|
|
)
|
|
assert p["sameRev"] == 1 # 같은 노선 = 판 번호 그대로
|
|
assert p["adoptedDraft"] and all(a["draft"] and not a["saved"] for a in p["adoptedDraft"])
|
|
assert p["afterRevert"]["session"] is None and p["afterRevert"]["last"]["draft"] is False
|
|
assert p["disposed"] is None
|
|
assert p["survived"] == r["n"] # 떠났다 다시 열어도 초안 살아남음
|
|
assert p["reopened"] and p["reopened"][-1]["draft"] is True
|
|
assert p["grade"]["sources"] == ["grade"] and "stations" not in p["grade"]["stages"]
|
|
assert p["clearedDraft"] is None # clearDrafts 가 함께 비움
|
|
assert p["remoteCalls"] == 0 # 격자 있으면 서버 길 안 탐
|