feat(511-B B-5 follow 줄 엶): 계산 자리 follow(P5 P6 F1 F2) 바꿀 수 있음 · 기본 both 그대로 · 「서버」 = 끌기 도구 꼴(끌기 중 화면 셈 · 잠잠 뒤 창구 /calc stage.follow until follow → 서버 Node route · follow 두 단계 · 답 오면 묶음을 노선부터 다시 돌려 서버 값으로 · 곁 자료 plusAt · followedAt 같이 돌려받아 다시 붙임 · 열쇠 = 노선 초안 + 저장 계획고) · 저장 정본 맥락(노선 계획 · 종류 · 정의 · 관 · 구조물 · 추가 측점 · 측점 간격 · 저장 종단) = 창구가 프로젝트 id 로 모음(B06_Section_Router_Calc_Context · 브라우저 정본 칸 버림 · 정본 쓰기 없음) · 묶음 refresh(입력 그대로 다시 돎) · 계획고 옮김 = 끌기 중 화면이 옮긴 입력 그대로(서버 follow 는 옮김 기준 없음) · 검증_sub3 끌기 네 걸음 뒤 서버 = 화면 0 차(관 · 입력 · 첫 계획선 · 종단 PVI 61 · 왕복 231 ms · Node 5 ms) · Node 두 단계 = 묶음 첫 바퀴 0 차(golden) · 까닭 글 follow · pair_B5 걷음 — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 15:56:39 +09:00
co-authored by Claude Opus 5.5
parent 29e1e4be6d
commit de6975363d
13 changed files with 488 additions and 53 deletions
@@ -6,6 +6,7 @@
"quantity": "both",
"carry": "both",
"route": "both",
"follow": "both",
"stations": "both",
"profile": "both",
"fixed_browser": "both"
@@ -64,6 +64,7 @@ def test_묶음_여덟_줄_등록부_전부():
"quantity",
"carry",
"route",
"follow",
"stations",
"profile", # 511-B B-3 엶
"fixed_browser",
@@ -77,7 +77,6 @@ def test_단계_id_표_같음_등록부_밖():
def test_판_다섯_줄_잠금_기본값_그대로():
by_key = {g["key"]: g for g in calc_sites.GROUPS}
expected = {
"follow": (["P5", "P6", "F1", "F2"], "pair_B5"),
"cross": (["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"], "pair_B4"),
}
for key, (nos, why) in expected.items():
@@ -92,6 +91,7 @@ def test_판_다섯_줄_잠금_기본값_그대로():
"quantity",
"carry",
"route", # B-5 = 엶(511-B B-5)
"follow", # B-5 = 엶(511-B B-5)
"stations", # B-2 끝 = 엶(511-B B-2)
"profile", # B-3 엶(511-B · test_511_b3_profile_node)
"fixed_browser",
@@ -104,7 +104,7 @@ def test_판_다섯_줄_잠금_기본값_그대로():
).stdout
assert calc_sites.PATH.read_bytes().replace(b"\r\n", b"\n") == committed.replace(b"\r\n", b"\n")
locale = (ROOT / "ui_template" / "ui_template_locale_m1.ts").read_text(encoding="utf-8")
for n in (4, 5):
for n in (4,):
assert f'B01_CalcSites_Why_pair_B{n}: ["짝 모으는 중 · B-{n}"' in locale
+198
View File
@@ -16,6 +16,7 @@
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
@@ -319,3 +320,200 @@ def test_화면_서버_노선_화면_셈과_같음(screens, case):
assert both["calls"] == []
# 저장 노선(초안 없음) = 서버 안 부름 · 초안 = server 판만 서버 노선
assert bool(server["calls"]) is (case == "draft")
# ── follow 「서버」 — 창구 맥락 · Node route · follow 두 단계 ─────────────────────────
def test_창구_follow_맥락_브라우저_정본칸_버림(monkeypatch):
from B06_Section import B06_Section_Router_Calc_Context as ctx
async def context(_pid):
return {"plan": {"planned": "서버"}, "saved_pipes": ["서버"], "longitudinal": None}
monkeypatch.setattr(ctx, "follow_context", context)
inputs = {
"stages": {
"until": "follow",
"input": {"route": 1},
"saved_edits": None,
"plan": {"planned": "브라우저"},
"saved_pipes": ["브라우저"],
"grid": {"path": "C:/x"},
"detail": {"x": 1},
}
}
assert asyncio.run(ctx.attach_follow_context("p", inputs)) is None
assert inputs["stages"] == {
"until": "follow",
"input": {"route": 1},
"saved_edits": None,
"plan": {"planned": "서버"},
"saved_pipes": ["서버"],
"longitudinal": None,
}
other = {"stages": {"plan": "브라우저"}} # until 없음 = 그대로(B-1 단계 길)
assert asyncio.run(ctx.attach_follow_context("p", other)) is None and other["stages"] == {
"plan": "브라우저"
}
def _golden_stages() -> dict:
from common_util.common_util_surface_sampler import dtm_grid_bytes
def fixture(name):
return json.loads((FIXTURE / name).read_text(encoding="utf-8"))
plan = fixture("plan.json")
nodes = [[n["x"], n["y"]] for n in plan["nodes"]]
at = len(nodes) // 2
nodes[at] = [nodes[at][0] + 6, nodes[at][1] + 4] # 교각점 하나 옮긴 노선 초안
draft = {
"nodes": nodes,
"curveOn": [n["radius_m"] is not None for n in plan["nodes"]],
"curveRadius": [None] * len(nodes),
"curveLock": [None] * len(nodes),
"curveArc": [None] * len(nodes),
"stationSnap": bool(plan.get("station_snap")),
"stationSnapStep": plan.get("station_snap_step"),
}
grid = ROOT / "tmp" / "511b5_grid.bin"
if not grid.is_file():
grid.write_bytes(
dtm_grid_bytes(FIXTURE / "project/B04_PreProcess/models/dtm_classification_smooth.npz")
)
return {
"plan": plan,
"detail": fixture("detail.json"),
"types": fixture("structure_types.json")["types"],
"saved_pipes": [
{k: v for k, v in p.items() if k != "lonlat"}
for p in fixture("pipes.json")["pipe_points"]
],
"saved_structures": fixture("structures.json")["structures"],
"interval_m": 20,
"grid": {"path": str(grid), "design": "las"},
"input": {"route": draft, "plusStations": fixture("plus_stations.json")["chainages"]},
}
def test_Node_follow_두_단계_묶음_첫_바퀴와_같음():
from common_util import common_util_calc_runner as runner
stages = _golden_stages()
full = runner.run_calc_served(["stage.route", "stage.follow"], {"stages": stages})
only = runner.run_calc_served(
["stage.route", "stage.follow"], {"stages": {**stages, "until": "follow", "grid": None}}
)
assert full and only and "error" not in full and "error" not in only, (full, only)
assert only["results"]["stage.route"] == full["results"]["stage.route"]
got = only["results"]["stage.follow"]
assert got["patch"] == full["results"]["stage.follow"]
assert got["patch"]["pipes"] and got["followed"] # 관 따라감 · 따라가기만 한 자리 곁 자료
# ── 화면 — follow 「서버」 끌기 여러 걸음 뒤 놓음 = 화면 셈(검증_sub3 · Playwright · 저장 안 함) ──
JS_DRAG = r"""async (steps) => {
const urls = performance.getEntriesByType('resource').map((e) => e.name);
const mod = async (re) => import(urls.find((n) => re.test(n)));
const page = await mod(/design_pipeline_page\.ts/);
const pid = localStorage.getItem('frd_current_project_id');
const plan = await (await mod(/B05_Profile_Api_Replan\.ts/)).fetchRoutePlan(pid);
const flat = (await mod(/B05_Profile_UI_RouteEdit_Edits\.ts/))
.flattenServerPlan(plan.nodes, plan.curves ?? [], plan.node_edits);
const bridge = page.routeDraftBridge(pid, null);
const at = Math.floor(flat.planned.length / 2);
for (let k = 1; k <= steps; k += 1) { // 끌기 걸음 — 걸음마다 묶음이 돎(잠잠 안 기다림)
const nodes = flat.planned.map(([x, y]) => [x, y]);
nodes[at] = [nodes[at][0] + 2 * k, nodes[at][1] + 1.5 * k];
bridge.edited({
nodes,
curveOn: flat.curveOn.map((on) => on !== false),
curveRadius: flat.curveRadius.map((r) => r ?? null),
curveLock: flat.curveLock ?? nodes.map(() => null),
curveArc: flat.curveArc ?? nodes.map(() => null),
stationSnap: plan.station_snap ?? false,
stationSnapStep: plan.station_snap_step ?? null,
});
await new Promise((r) => setTimeout(r, 60));
}
await page.pagePipeline(pid).settled();
}"""
JS_FOLLOW = r"""async () => {
const urls = performance.getEntriesByType('resource').map((e) => e.name);
const page = await import(urls.find((n) => /design_pipeline_page\.ts/.test(n)));
const pid = localStorage.getItem('frd_current_project_id');
const pipe = page.pagePipeline(pid);
await page.reconcilePagePipeline(pid);
await pipe.settled();
const plain = (v) => JSON.parse(JSON.stringify(v ?? null));
const calls = (window.__aisloCalcServer?.calls ?? []).filter((c) => c.id === 'stage.follow');
const planline = pipe.output('planline');
return {
follow: plain(pipe.output('follow')),
inputs: plain(['pipes', 'structures', 'plusStations', 'alignment']
.map((k) => [k, pipe.input(k)])),
base_pvi: plain(planline?.basePvi), pvi: plain(pipe.output('profile')?.pvi),
calls: calls.map((c) => ({ round: c.roundMs, node: c.nodeMs, bytes: c.bytes })),
runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome),
};
}"""
@pytest.fixture(scope="module")
def drags():
from test_511_b1_calc_node_stages import _UP, BASE, _started
if not BASE or _UP is None or _UP != _started(BASE.replace("5178", "8005")):
pytest.skip("이 폴더 서버 없음")
sync = pytest.importorskip("playwright.sync_api")
cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
shots = {}
with sync.sync_playwright() as play:
chrome = play.chromium.launch(channel="msedge", headless=True)
try:
for mode in ("both", "server"):
context = chrome.new_context(viewport={"width": 1600, "height": 1000})
login = {k: cred[k] for k in ("email", "password")}
r = context.request.post(
f"{BASE}/api/auth/login/request", data=login, timeout=60000
)
assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음
if mode == "server": # 판은 안 고침 — 이 창이 받는 판만 follow = server
def board(route):
body = route.fetch().json()
body["sites"]["follow"] = "server"
route.fulfill(json=body)
context.route("**/api/dashboard/calc-sites", board)
page = context.new_page()
page.add_init_script(
f"localStorage.setItem('frd_current_project_id', '{VERIFY}');"
" performance.setResourceTimingBufferSize(20000);"
)
page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded")
page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000)
page.wait_for_timeout(9000)
page.evaluate(JS_DRAG, 4) # 끌기 네 걸음(세션 초안 · 창과 함께 버림)
page.wait_for_timeout(4000) # 잠잠 400 ms · 서버 · 다시 돎
shots[mode] = page.evaluate(JS_FOLLOW)
context.close()
finally:
chrome.close()
(ROOT / "tmp" / "511b5_drags.json").write_text(json.dumps(shots), encoding="utf-8")
return shots
def test_화면_follow_서버_끌기_뒤_화면_셈과_같음(drags):
both, server = drags["both"], drags["server"]
assert both["runs"] == server["runs"] == ["done"]
assert both["calls"] == [] and server["calls"] # server 판만 놓은 뒤 서버 follow
# 계획고 옮김 = 끌기 중 화면 셈이 입력에 옮겨 둔 값 그대로(서버 follow 는 저장분에서 새로 셈 · 옮김
# 기준(앞 노선 · 앞 첫 계획선) 없음 → patch 에 alignment 안 냄) — 입력 · 종단은 같아야 함
drop = lambda patch: {k: v for k, v in patch.items() if k != "alignment"} # noqa: E731
assert drop(server["follow"]) == drop(both["follow"])
for key in ("inputs", "base_pvi", "pvi"):
assert server[key] == both[key], key