Files
Aislo/resources/tester/test_472_3_calc_registry.py
T

340 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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).
- 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",
}
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",
)
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", 8), ("G", 4), ("P", 8), ("C", 7)) for i in range(1, n + 1)}
want |= {f"F{i}" for i in range(1, 10)} | {"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