"""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` · 면적 · 다단 벽 · 누가토량 끝값 · 배분 두 벌 · corridor 3D = corridor 묶음) · 터파기 = 걷기 전 파이썬 값. ./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", "profile.base", "profile.alignment", "profile.carry", "facility.plus_follow", "facility.follow", "cross.design", "cross.berm_spans", "facility.rebase", "stations.carry", "facility.attach", # 511-B B-4 — GET 형상 얹기 "cross.enforce", # 511-B B-4 — [저장] 횡단 한 바퀴 "stations.list", "stations.sections", "route.polyline", "stations.plan", } pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치", ) # 화면 · 세션 끌림(목록 1.3) — 472-2 2-B · 518-3(측점 간격 → common_util_station)이 걷음. SESSION_PULLS: tuple[str, ...] = () # 기하 끌림(구조물 그림 파일) — 518-2 · 518-3 이 logic 으로 옮겨 걷음. GEOMETRY_PULLS: tuple[str, ...] = () # 묶음 단계 입구(511-B B-1) planline 저장 기준(`plantedBase`) 끌림 11 = B-3 이 logic 으로 옮겨 걷음 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) # 511-C(요구 321) — 유토 배분은 판 haul = both 때만 dynamic import(첫 묶음 밖) · 정적 import 0 lazy = "common_util/common_util_mass_haul_balance.ts" assert [path for path in server if path in browser] == [lazy] static = re.compile( r'^import\s+(?!type\s)[^;]*?from\s+"@util/common_util_mass_haul_balance"', re.M ) ts_files = [p for p in browser if p.endswith(".ts")] assert [p for p in ts_files if static.search((ROOT / p).read_text(encoding="utf-8"))] == [] haul_api = (ROOT / "B05_Profile" / "B05_Profile_Api_HaulPlan.ts").read_text(encoding="utf-8") assert 'await import("@util/common_util_mass_haul_balance")' in haul_api 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_Node_입구_정본_값(golden_run) -> None: # 옛 server-calc 묶음 걷음(511-B B-6) — 값은 472-1 기준값 시험(`stored` · `server_save`) layouts = golden_run["structure.layouts"] assert layouts["areas"] and isinstance(layouts["extra_walls"], list) merged = {**golden_run["masshaul.cumulative"], **golden_run["haul.plan"]} assert merged["haul_plan"] and merged["haul_plan_drawing"] 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: # 파이썬 짝 걷음(511-B B-6 · 운영 부름 0) — 기대값 = 걷기 전 파이썬 값 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"]] == [0.7, 1.6, 2.5, None, None] assert [w["width_m"] for w in got["walls"]] == pytest.approx([0.7, 0.55, 0.6], abs=1e-12) assert got["walls"][0]["area_m2"] == pytest.approx(1.85) assert got["walls"][1]["area_m2"] == pytest.approx(0.73) assert got["walls"][2]["area_m2"] is None def test_모르는_id_는_실패() -> None: assert _run({"run": ["stations.list"], "inputs": {}}) is None