- POST /design/save 에 route(노선 초안) · carry(옛 노선 세션 칸) 칸 · 서버 = 노선 저장 → Node facility.rebase(rebasePipeDraft · rebaseStructureDraft) → 6-A 차례(새 노선) → Node stations.carry(routeCarryPatches) → 횡단 저장 - 되돌림 범위 = 노선 행 · 자식 표 · 단계 · 배수 폴더 · 노선 파일 · 지표면 확정(설계 지형 바꿈) - 손 상단측은 안 넘김(요구 274) · 노선 바뀐 [확정] = [저장](새 노선 본 뒤 다시) - 브라우저 = 노선 초안 + 사용자 칸만 한 본문 · 옛 차례 길(saveRouteDraft 뒤 flush · carryAfterRouteSave · keepDraftsOnNewRoute) · 부름 0 된 flush 창구 여섯 · saveCachedCrossPatches 걷음 - 등록부 R9 stations.carry · F10 facility.rebase · 옛 차례 단언 시험 한 본문 꼴로 · test_472_6_route_save(옛 길 = 새 길 · 실패 되돌림) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
345 lines
14 KiB
Python
345 lines
14 KiB
Python
"""PLAN 472-3 (요구 273) — 계산 등록부 · 서버 Node 입구 · 경계 시험.
|
||
|
||
등록부 = `A00_Common/design/logic/design_calc_registry.ts`(순수 자료 · 목록 1.1 표 # 하나 = id 하나)
|
||
Node 입구 = `A00_Common/design/logic/design_calc_node.ts`(`build:design-calc` · id → 함수 표).
|
||
|
||
못박는 것
|
||
- id · # 하나씩 · 1.1 표 # 전부(R1 – N1 · 43) + R9 · F10(472-6 6-B).
|
||
- id 마다 실행기 있음 — `node:` 는 입구 표에 · `py:` · `browser:` · ts · 짝 이름은 그 파일에 ·
|
||
거울 시험 파일 있음.
|
||
- 묶음 `STAGE_DEPS`(`design_pipeline.ts`) = 등록부 입력에서 만든 기댐(같은지만 · 묶음 안 고침).
|
||
- 서버 쪽(server_canon · server_only) TS 가 브라우저 import 그래프에 없음 · 등록부는 안 끔.
|
||
- Node 입구 도달 집합 = logic · common_util · 자료만 + 「알려진 끌림」(`KNOWN_PULLS`) — 그 밖은
|
||
실패 · 안 나타난 알려진 끌림도 실패(목록에서 지움 — 472-2 2-B · 472-4 가 하나씩 걷어 빈 목록).
|
||
- Node 입구 값 = 기존 묶음 값(고정 자료 `fixtures/golden_472` · server-calc 면적 · 다단 벽 ·
|
||
누가토량 · 배분 · corridor 3D) · 터파기 = 파이썬 짝.
|
||
|
||
./venv/Scripts/python.exe -m pytest resources/tester/test_472_3_calc_registry.py -q
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
ROOT = HERE.parents[1]
|
||
FIXTURE = HERE / "fixtures" / "golden_472"
|
||
REGISTRY = "A00_Common/design/logic/design_calc_registry.ts"
|
||
REGISTRY_PARTS = (REGISTRY, "A00_Common/design/logic/design_calc_registry_facility.ts")
|
||
NODE_ENTRY = "A00_Common/design/logic/design_calc_node.ts"
|
||
BROWSER_ENTRY = "A00_Common/main.ts"
|
||
BUNDLE = ROOT / "config" / "design_calc_node" / "design_calc_node.js"
|
||
NODE_IDS = {
|
||
"structure.layouts",
|
||
"masshaul.cumulative",
|
||
"haul.plan",
|
||
"excavation.trench",
|
||
"corridor.mesh",
|
||
"profile.seat",
|
||
"facility.plus_follow",
|
||
"facility.follow",
|
||
"cross.design",
|
||
"cross.berm_spans",
|
||
"facility.rebase",
|
||
"stations.carry",
|
||
}
|
||
|
||
pytestmark = pytest.mark.skipif(
|
||
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
|
||
reason="프론트엔드 의존성 미설치",
|
||
)
|
||
|
||
# 화면 · 세션 끌림(목록 1.3) — 472-2 2-B 가 걷음. 남은 줄 = 헬퍼 정규식 헛잡음
|
||
# (Corridor_Structures_Hash 머리 주석 속 「import」부터 잡아 아래 `import type` 을 값으로 셈) —
|
||
# 헬퍼를 고치면 빈 목록.
|
||
SESSION_PULLS = (
|
||
"B06_Section/B06_Section_Api_Fetch.ts",
|
||
"config/config_frontend.ts",
|
||
"ui_template/ui_template_elements.ts",
|
||
"ui_template/ui_template_elements_base.ts",
|
||
"ui_template/ui_template_elements_chart.ts",
|
||
"ui_template/ui_template_elements_styles.ts",
|
||
"ui_template/ui_template_locale.ts",
|
||
"ui_template/ui_template_locale_a.ts",
|
||
"ui_template/ui_template_locale_b1.ts",
|
||
"ui_template/ui_template_locale_b2.ts",
|
||
"ui_template/ui_template_locale_b3.ts",
|
||
"ui_template/ui_template_locale_b4.ts",
|
||
"ui_template/ui_template_locale_common.ts",
|
||
"ui_template/ui_template_locale_m1.ts",
|
||
"ui_template/ui_template_locale_m2.ts",
|
||
)
|
||
# 기하가 logic 밖(구조물 그림 · 3D 빌더 파일 안) — 472-4 가 logic · common_util 로.
|
||
GEOMETRY_PULLS = (
|
||
"B05_Profile/B05_Profile_UI_Corridor_Build.ts",
|
||
"B05_Profile/B05_Profile_UI_Corridor_Envelope.ts",
|
||
"B05_Profile/B05_Profile_UI_Corridor_Station.ts",
|
||
"B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts",
|
||
"B05_Profile/B05_Profile_UI_Corridor_Structures.ts",
|
||
"B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts",
|
||
"B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts",
|
||
"B05_Profile/B05_Profile_UI_Corridor_Structures_Path.ts",
|
||
"B06_Section/B06_Section_Structure_Layouts.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Body_Guard.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Box.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Box_Geom.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Culvert.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Culvert_Types.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Design_Surface.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Excavation.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Ford.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Ford_Geom.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Revetment.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Wall.ts",
|
||
"B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts",
|
||
# 노선 바뀐 [저장] 넘김 patch(472-6 6-B) — 세션 칸 → 설계 칸 얹기가 관 벽 · 단 · 세월 · BOX 기본값(위 그림 파일)을 씀
|
||
"B06_Section/B06_Section_Route_Carry_Patch.ts",
|
||
)
|
||
KNOWN_PULLS = set(SESSION_PULLS) | set(GEOMETRY_PULLS)
|
||
|
||
|
||
def _node(*args: str, timeout: int = 120) -> str:
|
||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||
["node", *args], cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=timeout
|
||
)
|
||
if proc.returncode:
|
||
raise RuntimeError(proc.stderr)
|
||
return proc.stdout
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def loaded(tmp_path_factory) -> dict:
|
||
"""vite ssr 로 등록부 + 묶음 단계 기댐을 묶어 JSON 으로(472-1 꼴)."""
|
||
work = tmp_path_factory.mktemp("reg")
|
||
_node(
|
||
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
|
||
"build",
|
||
"--configLoader",
|
||
"runner",
|
||
"--ssr",
|
||
"../resources/tester/helper_472_3_registry_entry.ts",
|
||
"--outDir",
|
||
str(work),
|
||
"--logLevel",
|
||
"error",
|
||
timeout=300,
|
||
)
|
||
bundle = work / "bundle.mjs"
|
||
shutil.copy(work / "helper_472_3_registry_entry.js", bundle)
|
||
url = bundle.as_uri()
|
||
out = _node(
|
||
"--input-type=module",
|
||
"-e",
|
||
f"const m = await import({json.dumps(url)}); console.log(JSON.stringify("
|
||
"{registry: m.CALC_REGISTRY, deps: m.STAGE_DEPS, "
|
||
"stages: [...m.BROWSER_STAGES, ...m.AFTER_STAGES]}))",
|
||
)
|
||
return json.loads(out)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def reach() -> dict[str, list[str]]:
|
||
out = _node(
|
||
str(HERE / "helper_472_3_graph.mjs"), str(ROOT), NODE_ENTRY, REGISTRY, BROWSER_ENTRY
|
||
)
|
||
return json.loads(out)
|
||
|
||
|
||
def _run(payload: dict) -> dict | None:
|
||
from common_util.common_util_node_bundle import run_bundle_json
|
||
|
||
return run_bundle_json(BUNDLE, "build:design-calc", payload)
|
||
|
||
|
||
def _fixture(name: str):
|
||
return json.loads((FIXTURE / name).read_text(encoding="utf-8"))
|
||
|
||
|
||
def _context() -> dict:
|
||
ctx = _fixture("context.json")
|
||
keys = ("earthwork_conversion", "haul_equipment_limits", "natural_spoil_min_ground_slope")
|
||
return {k: ctx[k] for k in keys}
|
||
|
||
|
||
def _has_name(ref: str) -> bool:
|
||
"""`<경로>#<이름>` — 파일 있음 · 그 이름이 정의됨(py def · ts export)."""
|
||
path, name = ref.split("#")
|
||
file = ROOT / path
|
||
if not file.is_file():
|
||
return False
|
||
src = file.read_text(encoding="utf-8")
|
||
if path.endswith(".py"):
|
||
return re.search(rf"^\s*(?:async\s+)?def {re.escape(name)}\(", src, re.M) is not None
|
||
pattern = rf"^export\s+(?:async\s+)?(?:function\*?|const|let|class)\s+{re.escape(name)}\b"
|
||
return re.search(pattern, src, re.M) is not None
|
||
|
||
|
||
# ── 등록부 자료 ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_id_하나씩_표_전부(loaded) -> None:
|
||
rows = loaded["registry"]
|
||
ids = [row["id"] for row in rows]
|
||
nos = [row["no"] for row in rows]
|
||
assert len(ids) == len(set(ids))
|
||
assert len(nos) == len(set(nos))
|
||
want = {f"{k}{i}" for k, n in (("R", 9), ("G", 4), ("P", 8), ("C", 7)) for i in range(1, n + 1)}
|
||
# R9 · F10 = 노선 바뀐 [저장] 다시 얹기(472-6 6-B)
|
||
want |= {f"F{i}" for i in range(1, 11)} | {"E1", "E2"} | {f"D{i}" for i in range(1, 5)} | {"N1"}
|
||
assert set(nos) == want
|
||
|
||
|
||
def test_id_마다_실행기_있음(loaded) -> None:
|
||
bad = []
|
||
for row in loaded["registry"]:
|
||
kind, _, ref = row["runner"].partition(":")
|
||
if row["site"] == "browser_only":
|
||
ok = kind == "browser" and _has_name(ref)
|
||
elif kind == "node":
|
||
ok = ref in NODE_IDS # 한 셈에서 나오는 값(면적 · 소단 사면)은 그 셈의 id
|
||
else:
|
||
ok = kind == "py" and _has_name(ref)
|
||
ok = ok and ("until" not in row or kind == "py")
|
||
ok = ok and all(_has_name(r) for r in (row["ts"], row["pair"]) if r)
|
||
ok = ok and all((HERE / f"{name}.py").is_file() for name in row["mirror"])
|
||
if not ok:
|
||
bad.append(row["id"])
|
||
assert bad == []
|
||
node_runs = {
|
||
row["runner"][5:] for row in loaded["registry"] if row["runner"].startswith("node:")
|
||
}
|
||
assert node_runs == NODE_IDS
|
||
|
||
|
||
def test_묶음_STAGE_DEPS_등록부_입력과_같음(loaded) -> None:
|
||
"""같은 단계 줄들의 inputs 중 `input.*` 은 그대로 · 다른 단계 id 는 그 단계 이름."""
|
||
rows = loaded["registry"]
|
||
stage_of = {row["id"]: row["stage"] for row in rows}
|
||
derived: dict[str, set[str]] = {name: set() for name in loaded["stages"]}
|
||
for row in rows:
|
||
if not row["stage"]:
|
||
continue
|
||
for item in row["inputs"]:
|
||
if item.startswith("input."):
|
||
derived[row["stage"]].add(item)
|
||
elif stage_of.get(item) and stage_of[item] != row["stage"]:
|
||
derived[row["stage"]].add(stage_of[item])
|
||
assert {k: set(v) for k, v in loaded["deps"].items()} == derived
|
||
# 앞 id 는 등록부에 있음(이름 틀림 막기)
|
||
ids = set(stage_of)
|
||
prefixes = ("input.", "file:", "session:", "context.", "store.")
|
||
stray = [
|
||
i for row in rows for i in row["inputs"] if not i.startswith(prefixes) and i not in ids
|
||
]
|
||
assert stray == []
|
||
|
||
|
||
# ── import 경계 ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_서버_쪽_코드_브라우저_밖(loaded, reach) -> None:
|
||
browser = set(reach[BROWSER_ENTRY])
|
||
server = [
|
||
row["ts"].split("#")[0]
|
||
for row in loaded["registry"]
|
||
if row["site"] in ("server_canon", "server_only") and row["ts"]
|
||
]
|
||
assert server # 유토 배분(common_util_mass_haul_balance)
|
||
assert [path for path in server if path in browser] == []
|
||
assert NODE_ENTRY not in browser
|
||
assert reach[REGISTRY] == sorted(REGISTRY_PARTS) # 순수 자료 — 제 몫 파일만(나눔 · 함수 안 끔)
|
||
|
||
|
||
def test_Node_입구_도달_경계(reach) -> None:
|
||
files = set(reach[NODE_ENTRY])
|
||
own = {
|
||
f for f in files if "/logic/" in f or f.startswith("common_util/") or f.endswith(".json")
|
||
}
|
||
outside = files - own
|
||
assert sorted(outside - KNOWN_PULLS) == [] # 새 끌림
|
||
assert sorted(KNOWN_PULLS - outside) == [] # 걷힌 끌림 — KNOWN_PULLS 에서 지울 것
|
||
|
||
|
||
# ── Node 입구 값 ────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def golden_run() -> dict:
|
||
out = _run(
|
||
{
|
||
"run": ["haul.plan", "structure.layouts", "masshaul.cumulative"],
|
||
"inputs": {"detail": _fixture("detail.json"), "context": _context()},
|
||
}
|
||
)
|
||
assert isinstance(out, dict)
|
||
assert set(out["available"]) == NODE_IDS
|
||
return out["results"]
|
||
|
||
|
||
def test_server_calc_묶음과_같음(golden_run) -> None:
|
||
from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT
|
||
from B06_Section.B06_Section_Server_Calc_Prebuild import BUNDLE as SC_BUNDLE
|
||
from common_util.common_util_node_bundle import run_bundle_json
|
||
|
||
sc = run_bundle_json(
|
||
SC_BUNDLE, _NPM_SCRIPT, {"detail": _fixture("detail.json"), "context": _context()}
|
||
)
|
||
layouts = golden_run["structure.layouts"]
|
||
assert layouts["areas"] == sc["areas"] and sc["areas"]
|
||
assert layouts["extra_walls"] == sc["extra_walls"]
|
||
merged = {**golden_run["masshaul.cumulative"], **golden_run["haul.plan"]}
|
||
assert merged == sc["mass_haul"]
|
||
assert merged["final_cumulative_m3"] == -17570.7 # 472-1 화면 참값
|
||
|
||
|
||
def test_corridor_묶음과_같음() -> None:
|
||
from B05_Profile.B05_Profile_Corridor_Prebuild import BUNDLE as CO_BUNDLE
|
||
from common_util.common_util_node_bundle import run_bundle_json
|
||
|
||
detail = _fixture("detail.json")
|
||
points = [
|
||
{"x": x, "y": y, "order": i} for i, (x, y) in enumerate(_fixture("plan.json")["planned"])
|
||
]
|
||
co = run_bundle_json(CO_BUNDLE, "build:corridor", {"detail": detail, "route_points": points})
|
||
out = _run({"run": ["corridor.mesh"], "inputs": {"detail": detail, "route_points": points}})
|
||
assert co and out["results"]["corridor.mesh"] == co
|
||
|
||
|
||
def test_터파기_파이썬_짝과_같음() -> None:
|
||
from common_util import common_util_excavation as py
|
||
|
||
pipes = [{"diameter_mm": d} for d in (300, 800, 1500, 250, None)]
|
||
walls = [
|
||
{"height_m": 2.0, "average_thickness_m": 0.5, "has_foundation": True},
|
||
{"height_m": 1.2, "average_thickness_m": 0.35, "has_foundation": False},
|
||
{"height_m": None, "average_thickness_m": 0.4, "has_foundation": True},
|
||
]
|
||
out = _run(
|
||
{"run": ["excavation.trench"], "inputs": {"excavation": {"pipes": pipes, "walls": walls}}}
|
||
)
|
||
got = out["results"]["excavation.trench"]
|
||
assert [p["width_m"] for p in got["pipes"]] == [
|
||
py.pipe_trench_width_m(p["diameter_mm"]) for p in pipes
|
||
]
|
||
for row, wall in zip(got["walls"], walls, strict=True):
|
||
assert row["width_m"] == py.wall_trench_width_m(wall["average_thickness_m"])
|
||
want = py.wall_trench_area_m2(
|
||
wall["height_m"], wall["average_thickness_m"], has_foundation=wall["has_foundation"]
|
||
)
|
||
assert row["area_m2"] == pytest.approx(want) if want is not None else row["area_m2"] is None
|
||
|
||
|
||
def test_모르는_id_는_실패() -> None:
|
||
assert _run({"run": ["stations.list"], "inputs": {}}) is None
|