merge(511-B B-5 follow 합치기): origin/sub_laptop_3(follow 줄 엶 · /calc follow 서버 맥락) 받음 — 공유 파일 충돌 풂

- /calc 창구 차례 = 격자 경로 막기 → 단계 · 화면 길 서버 맥락(B-4) → follow 저장 정본 맥락(B-5 · stages 다시 짬)
- 까닭 글 = cross 그대로 · pair_B5 걷음(follow 엶)
- 시험 511_b1 · 309_2 · 309_2_browser = 잠긴 단계 줄 없음 · 잠금 예시 = form_default · 화면 열린 줄에 P5 P6 F1 F2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 16:02:25 +09:00
co-authored by Claude Opus 5.5
14 changed files with 493 additions and 59 deletions
@@ -6,6 +6,7 @@
"quantity": "both",
"carry": "both",
"route": "both",
"follow": "both",
"stations": "both",
"profile": "both",
"cross": "both",
+2 -1
View File
@@ -64,6 +64,7 @@ def test_묶음_여덟_줄_등록부_전부():
"quantity",
"carry",
"route",
"follow",
"stations",
"profile", # 511-B B-3 엶
"cross", # 511-B B-4 끝에 엶
@@ -162,7 +163,7 @@ def test_읽기_로그인_쓰기_시스템_관리자만(client):
def test_잠금_모르는_줄_400_감사_한_줄(client):
http, _role, board, log = client
url = "/api/dashboard/admin/calc-sites"
for bad in ({"follow": "server"}, {"fixed_server": "both"}, {"nope": "server"}):
for bad in ({"form_default": "server"}, {"fixed_server": "both"}, {"nope": "server"}):
assert http.put(url, json={"sites": bad}).status_code == 400, bad
assert http.put(url, json={"sites": {"ground_sample": "browser"}}).status_code == 400
assert log == []
@@ -150,13 +150,13 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser):
calcs = [row for row in rows if not row["head"]]
assert len(heads) == 13 and len(calcs) == 45 # 511-B 단계 다섯 줄 · F9 · 511-C haul
whys = {row["name"].split(" · ")[0]: row["why"] for row in calcs}
for no, n in {"F2": 5}.items():
assert whys[no] == f"짝 모으는 중 · B-{n}", no
assert not any(why.startswith("짝 모으는 중") for why in whys.values()) # 단계 줄 모두 엶
assert whys["C1"].startswith("끄면 횡단을 서버 Node") # 511-B B-4 엶
assert heads[0]["boxes"] == [[True, False], [True, True]] # G1 묶음 머리 = 바꿀 수 있음
editable = [row["name"] for row in calcs if not row["boxes"][0][1]]
opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더
opened += ["R1", "R3", "R5", "R6", "R7", "F3"] # 511-B B-5 노선 · B-2 측점
opened += ["R1", "R3", "P5", "P6", "F1", "F2"] # 511-B B-5 노선 · 따라감
opened += ["R5", "R6", "R7", "F3"] # 511-B B-2 측점
opened += ["P1", "P2", "P3", "P4", "P8"] # 511-B B-3 종단 엶
opened += ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"] # 511-B B-4
opened += ["E2"] # 511-C 유토 배분 엶(기본 server = 체크 꺼짐)
@@ -76,9 +76,7 @@ def test_단계_id_표_같음_등록부_밖():
def test_판_다섯_줄_잠금_기본값_그대로():
by_key = {g["key"]: g for g in calc_sites.GROUPS}
expected = {
"follow": (["P5", "P6", "F1", "F2"], "pair_B5"),
}
expected = {}
for key, (nos, why) in expected.items():
group = by_key[key]
assert group["nos"] == nos and group["why"] == why, key
@@ -95,6 +93,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)
"cross",
@@ -109,7 +108,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