Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4

This commit is contained in:
2026-10-19 08:55:00 +09:00
27 changed files with 1584 additions and 324 deletions
+51
View File
@@ -0,0 +1,51 @@
/* 472-3 헬퍼 — TS import 도달 집합(472-0 g472/graph.mjs 꼴 · 읽기만).
* node helper_472_3_graph.mjs <저장소 뿌리> <입구.ts> [<입구.ts> …]
* 입구마다 도달 파일(뿌리 상대 · `/`)을 JSON 한 벌로 표준출력 — 정적 import · export from · 동적
* import() · new URL(…, import.meta.url)(워커) · `import type` · `export type` 은 뺌(지워짐). */
import fs from "node:fs";
import path from "node:path";
const [root, ...entries] = process.argv.slice(2);
const alias = { "@ui/": "ui_template/", "@config/": "config/", "@util/": "common_util/" };
const rel = (p) => path.relative(root, p).split(path.sep).join("/");
const exists = (p) => {
for (const c of [p, `${p}.ts`, `${p}/index.ts`])
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
return null;
};
function resolve(from, spec) {
let base;
if (spec.startsWith(".")) base = path.resolve(path.dirname(from), spec);
else {
const a = Object.keys(alias).find((k) => spec.startsWith(k));
if (!a) return null;
base = path.resolve(root, alias[a] + spec.slice(a.length));
}
return exists(base.replace(/\.js$/, ""));
}
const re =
/(?:import|export)\s+(?:type\s+)?(?:[^;"'`]*?\sfrom\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|new URL\(\s*["']([^"']+)["']\s*,\s*import\.meta\.url/g;
function deps(file) {
if (!/\.(ts|mts)$/.test(file)) return [];
const src = fs.readFileSync(file, "utf8");
const out = [];
for (const m of src.matchAll(re)) {
if (/^(?:import|export)\s+type\s/.test(m[0])) continue;
const target = resolve(file, m[1] || m[2] || m[3]);
if (target) out.push(target);
}
return out;
}
const result = {};
for (const entry of entries) {
const seen = new Set();
const stack = [path.resolve(root, entry)];
while (stack.length) {
const file = stack.pop();
if (seen.has(file)) continue;
seen.add(file);
stack.push(...deps(file));
}
result[entry] = [...seen].map(rel).sort();
}
process.stdout.write(JSON.stringify(result));
@@ -0,0 +1,7 @@
/* 472-3 헬퍼 — 등록부와 묶음 단계 기댐을 한 묶음으로(vite ssr) · test_472_3_calc_registry.py 가 JSON 으로 받음. */
export { CALC_REGISTRY } from "../../A00_Common/design/logic/design_calc_registry";
export {
AFTER_STAGES,
BROWSER_STAGES,
STAGE_DEPS,
} from "../../A00_Common/design/logic/design_pipeline";
@@ -33,6 +33,8 @@ POPOUT_FILES = [
"B05_Profile/B05_Profile_UI_Drainage_Panel.ts",
"B05_Profile/B05_Profile_UI_Structures_Marks.ts",
"common_util/common_util_mass_haul_view.ts",
"common_util/common_util_mass_haul_callout.ts",
"common_util/common_util_mass_haul_legend.ts",
]
# 메인 창 전역 틀 · 시계 — `x.requestAnimationFrame` 처럼 창을 짚은 것은 제외
MAIN_WINDOW_CLOCK = re.compile(
@@ -0,0 +1,338 @@
"""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"
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",
}
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
reason="프론트엔드 의존성 미설치",
)
# 화면 · 세션 끌림(목록 1.3) — 472-2 2-B 가 정리(PIPE_DISPLAY_NAME → common_util · Api_Fetch 세션 ·
# 구조물 그림이 고른 것 읽기 · 사진첩).
SESSION_PULLS = (
"A00_Common/b_page_state.ts",
"A00_Common/design/design_pick_match.ts",
"A00_Common/design/design_pipe_ids.ts",
"A00_Common/design/design_selection.ts",
"A00_Common/design/design_store.ts",
"B05_Profile/B05_Profile_UI_IrregularStations.ts",
"B05_Profile/B05_Profile_UI_Profile_History.ts",
"B06_Section/B06_Section_Api_Fetch.ts",
"B06_Section/B06_Section_UI_Section_Common.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 == row["id"] and ref in NODE_IDS
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_rows = {row["id"] for row in loaded["registry"] if row["runner"].startswith("node:")}
assert node_rows == 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] == [REGISTRY] # 순수 자료 — 아무것도 안 끔
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
@@ -0,0 +1,70 @@
"""PLAN 472-3 3-B (요구 273) — 파이썬 실행기 `common_util_calc_runner` → Node 입구 → 기존 묶음 값.
못박는 것
- `NODE_IDS` = 등록부 `node:` 줄 = 입구 표(`available`).
- 고정 자료 `fixtures/golden_472` 로 `run_calc` 값 = [저장] 재계산이 부르는 server-calc 묶음 값
(`Server_Calc_Prebuild` BUNDLE · 면적 · 다단 벽 · 누가토량 · 배분 · 끝값 −17,570.7).
- Node 실행기 아닌 id(파이썬 · 브라우저 실행기)는 ValueError.
./venv/Scripts/python.exe -m pytest resources/tester/test_472_3_calc_runner.py -q
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from common_util import common_util_calc_runner as runner
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
FIXTURE = HERE / "fixtures" / "golden_472"
REGISTRY = ROOT / "A00_Common" / "design" / "logic" / "design_calc_registry.ts"
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
reason="프론트엔드 의존성 미설치",
)
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 test_NODE_IDS_등록부_입구와_같음() -> None:
src = REGISTRY.read_text(encoding="utf-8")
assert set(re.findall(r'runner: "node:([a-z_.]+)"', src)) == runner.NODE_IDS
out = runner.run_bundle_json(runner.BUNDLE, runner.NPM_SCRIPT, {"run": [], "inputs": {}})
assert set(out["available"]) == runner.NODE_IDS
def test_실행기_값_server_calc_와_같음() -> None:
from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE
from common_util.common_util_node_bundle import run_bundle_json
sc = run_bundle_json(
BUNDLE, _NPM_SCRIPT, {"detail": _fixture("detail.json"), "context": _context()}
)
got = runner.run_calc(
["structure.layouts", "masshaul.cumulative", "haul.plan"],
{"detail": _fixture("detail.json"), "context": _context()},
)
assert got["structure.layouts"]["areas"] == sc["areas"] and sc["areas"]
assert got["structure.layouts"]["extra_walls"] == sc["extra_walls"]
merged = {**got["masshaul.cumulative"], **got["haul.plan"]}
assert merged == sc["mass_haul"]
assert merged["final_cumulative_m3"] == -17570.7
def test_Node_실행기_아닌_id_는_ValueError() -> None:
with pytest.raises(ValueError, match="stations.list"):
runner.run_calc(["masshaul.cumulative", "stations.list"], {})