⚠ **뿌리** — `tmp/` 는 창 사이에 안 건너감(실측 확인: 상대 창이 놓은 `tmp/_sync_probe.txt` 가 시간을 두고 두 번 봐도 안 보임). 그래서 **정본(등록부 스키마)만 건너가고 그것을 읽는 시험은 안 건너가** 오늘 두 번, 같은 시험이 **연 창은 통과·받은 창은 실패**가 됐음. - `tmp/tests/*` 를 `resources/tester/` 로 **복사**(127 파일). 내용은 **한 줄도 안 고침** - `tmp/tests` 는 **남겨 둠** — 되돌릴 자리(사용자 지시) - 실행: `./venv/Scripts/python.exe -m pytest resources/tester/ -q` 옮기기 전과 **같은 수**: 617 통과 / 22 건너뜀 / 실패 0 ⇒ 이제 시험·예외·까닭이 **정본과 함께** 움직임. 오늘 세운 「예외는 정본 스키마에」와 짝임. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
356 lines
13 KiB
Python
356 lines
13 KiB
Python
"""횡단 설계선·단면적 거울 테스트 — 파이썬 엔진과 TS 짝이 **같은 값**을 내는지 대조한다.
|
|
|
|
짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`)
|
|
↔ `common_util/common_util_cross_design.ts` (`computeCrossDesign`)
|
|
(단면적은 그 안에서 `..._Engine_Areas.py` ↔ `..._cross_design_areas.ts` 를 탄다)
|
|
|
|
**계산 자리 규칙**(CLAUDE.md 5장)이 「초기값은 서버, 조작 중은 브라우저, 코드는 한 벌」이라
|
|
이 짝은 반드시 같은 값을 내야 한다. 한쪽만 고치면 **화면에 보이는 수량과 저장되는 수량이
|
|
갈린다** — 그런데 이 짝에는 거울 테스트가 없었다(2026-09-07 신설).
|
|
|
|
표준단면 수치는 파이썬 config 를 그대로 TS 에 넘긴다 — 출처가 같아야 기본값 차이가 아니라
|
|
**산식 차이**만 잡힌다(배수관 세트 거울 테스트와 같은 방식).
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
|
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
|
|
|
|
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
|
|
|
# 값이 소수점 아래에서 갈리는지 보려면 자릿수를 그대로 비교해야 한다. 두 쪽 다 반올림해
|
|
# 내보내므로 완전 일치를 기대하되, 부동소수 마지막 자리만 어긋나는 것은 허용한다.
|
|
_TOLERANCE = 1e-9
|
|
|
|
|
|
def _ground(slope: float, *, gap: tuple[float, float] | None = None) -> list[dict]:
|
|
"""가로 -12m ~ +12m 를 0.5m 간격으로 훑은 지반선. `gap` 구간은 결측으로 둔다."""
|
|
samples = []
|
|
offset = -12.0
|
|
while offset <= 12.0001:
|
|
invalid = gap is not None and gap[0] <= offset <= gap[1]
|
|
samples.append(
|
|
{
|
|
"offset_m": round(offset, 3),
|
|
"elevation_m": None if invalid else round(100.0 + slope * offset, 4),
|
|
"valid": not invalid,
|
|
}
|
|
)
|
|
offset += 0.5
|
|
return samples
|
|
|
|
|
|
# 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교
|
|
# 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간.
|
|
CASES = [
|
|
{
|
|
"name": "토사 좌절토 일반측구",
|
|
"samples": _ground(0.35),
|
|
"design_elevation_m": 100.4,
|
|
"options": {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"},
|
|
},
|
|
{
|
|
"name": "리핑암 우절토 L형측구 2단경사",
|
|
"samples": _ground(-0.42),
|
|
"design_elevation_m": 99.6,
|
|
"options": {
|
|
"ground_type": "ripping_rock",
|
|
"section_mode": "right_cut",
|
|
"ditch_side": "right",
|
|
"ditch_type": "l_type",
|
|
"rock_boundary_offset_m": -0.8,
|
|
"two_stage_slope": True,
|
|
},
|
|
},
|
|
{
|
|
"name": "발파암 2단경사 해제",
|
|
"samples": _ground(0.55),
|
|
"design_elevation_m": 101.2,
|
|
"options": {
|
|
"ground_type": "blasting_rock",
|
|
"section_mode": "left_cut",
|
|
"ditch_side": "left",
|
|
"rock_boundary_offset_m": -1.4,
|
|
"two_stage_slope": False,
|
|
},
|
|
},
|
|
{
|
|
"name": "포장 + 측구 없음",
|
|
"samples": _ground(0.12),
|
|
"design_elevation_m": 100.05,
|
|
"options": {
|
|
"ground_type": "soil",
|
|
"section_mode": "left_cut",
|
|
"ditch_side": "left",
|
|
"paved": True,
|
|
"ditch_enabled": False,
|
|
},
|
|
},
|
|
{
|
|
"name": "세월교 노면 하강",
|
|
"samples": _ground(0.2),
|
|
"design_elevation_m": 100.6,
|
|
"options": {
|
|
"ground_type": "soil",
|
|
"section_mode": "right_cut",
|
|
"ditch_side": "right",
|
|
"surface_drop_m": 0.45,
|
|
},
|
|
},
|
|
{
|
|
"name": "곡선부 확폭 — 바깥쪽 좌",
|
|
"samples": _ground(0.28),
|
|
"design_elevation_m": 100.3,
|
|
"options": {
|
|
"ground_type": "soil",
|
|
"section_mode": "left_cut",
|
|
"ditch_side": "left",
|
|
"plan_radius_m": 18.0,
|
|
"curve_outer_side": "left",
|
|
},
|
|
},
|
|
{
|
|
"name": "곡선부 확폭 — 저장된 확폭량 우",
|
|
"samples": _ground(-0.3),
|
|
"design_elevation_m": 100.9,
|
|
"options": {
|
|
"ground_type": "soil",
|
|
"section_mode": "right_cut",
|
|
"ditch_side": "right",
|
|
"plan_radius_m": 25.0,
|
|
"curve_outer_side": "right",
|
|
"curve_widening_m": 0.62,
|
|
},
|
|
},
|
|
{
|
|
"name": "지반선 결측 구간(사면이 안 닫힐 수 있음)",
|
|
"samples": _ground(0.9, gap=(6.0, 12.0)),
|
|
"design_elevation_m": 100.0,
|
|
"options": {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"},
|
|
},
|
|
{
|
|
# 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 1:0.4 를 1:1.0 으로 눕힘.
|
|
# 무릎 아래(암)만 바뀌고 위(토사)는 표준 그대로라, 두 쪽이 같은 지점에서 꺾여야 한다.
|
|
"name": "암 절토 경사를 측점에서 바꿈",
|
|
"samples": _ground(-0.5),
|
|
"design_elevation_m": 99.8,
|
|
"options": {
|
|
"ground_type": "ripping_rock",
|
|
"section_mode": "left_cut",
|
|
"ditch_side": "left",
|
|
"rock_boundary_offset_m": -1.0,
|
|
"two_stage_slope": True,
|
|
"cut_slope_ratio": 1.0,
|
|
},
|
|
},
|
|
{
|
|
# 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토(2026-09-09 사용자 확정).
|
|
# ⚠ 그 바깥 성토는 노선 몫이 아니라 사토장 몫이라 `fill_area_m2` 에서 빠진다.
|
|
"name": "사토장이 선 측점",
|
|
"samples": _ground(-0.5),
|
|
"design_elevation_m": 99.8,
|
|
"options": {
|
|
"ground_type": "soil",
|
|
"section_mode": "left_cut",
|
|
"ditch_side": "left",
|
|
"spoil_fill": {"side": "left", "width_m": 4.0},
|
|
},
|
|
},
|
|
{
|
|
"name": "사토장 — 기울기를 따로 준 측점",
|
|
"samples": _ground(-0.5),
|
|
"design_elevation_m": 99.8,
|
|
"options": {
|
|
"ground_type": "soil",
|
|
"section_mode": "left_cut",
|
|
"ditch_side": "left",
|
|
"spoil_fill": {"side": "left", "width_m": 8.0, "slope_ratio_n": 2.0},
|
|
},
|
|
},
|
|
]
|
|
|
|
_TS_RUNNER = """
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { computeCrossDesign } from "./common_util_cross_design.js";
|
|
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
|
const camel = {
|
|
ground_type: "groundType",
|
|
section_mode: "sectionMode",
|
|
ditch_side: "ditchSide",
|
|
ditch_type: "ditchType",
|
|
paved: "paved",
|
|
rock_boundary_offset_m: "rockBoundaryOffsetM",
|
|
two_stage_slope: "twoStageSlope",
|
|
cut_slope_ratio: "cutSlopeRatio",
|
|
ditch_enabled: "ditchEnabled",
|
|
surface_drop_m: "surfaceDropM",
|
|
plan_radius_m: "planRadiusM",
|
|
curve_outer_side: "curveOuterSide",
|
|
curve_widening_m: "curveWideningM",
|
|
};
|
|
// 사토장 제원만 칸 이름이 다르다 — 파이썬은 snake, TS 는 camel 이다.
|
|
const spoilCamel = { side: "side", width_m: "widthM", slope_ratio_n: "slopeRatioN" };
|
|
const results = input.cases.map((item) => {
|
|
const options = { standard: input.standard };
|
|
for (const [key, value] of Object.entries(item.options)) {
|
|
if (key === "spoil_fill") {
|
|
const spoil = {};
|
|
for (const [k, v] of Object.entries(value)) spoil[spoilCamel[k]] = v;
|
|
options.spoilFill = spoil;
|
|
continue;
|
|
}
|
|
options[camel[key]] = value;
|
|
}
|
|
return computeCrossDesign(item.samples, item.design_elevation_m, options);
|
|
});
|
|
writeFileSync(process.argv[3], JSON.stringify(results));
|
|
"""
|
|
|
|
|
|
def _python_results() -> list[dict]:
|
|
results = []
|
|
for case in CASES:
|
|
results.append(
|
|
compute_cross_design(
|
|
case["samples"],
|
|
case["design_elevation_m"],
|
|
standard=STANDARD_CROSS_SECTION,
|
|
**case["options"],
|
|
)
|
|
)
|
|
return results
|
|
|
|
|
|
def _ts_results(tmp_path: Path) -> list[dict]:
|
|
"""TS 짝을 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다."""
|
|
out = tmp_path / "js"
|
|
subprocess.run( # noqa: S603 — 고정 실행 파일
|
|
[
|
|
"node",
|
|
str(TSC),
|
|
str(PROJECT_ROOT / "common_util" / "common_util_cross_design.ts"),
|
|
"--outDir",
|
|
str(out),
|
|
"--module",
|
|
"esnext",
|
|
"--target",
|
|
"es2022",
|
|
"--moduleResolution",
|
|
"bundler",
|
|
"--ignoreConfig",
|
|
],
|
|
cwd=str(PROJECT_ROOT),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
# tsc 가 낸 상대 import 에는 확장자가 없어 Node ESM 이 못 읽는다(번들러가 붙여 주던 몫).
|
|
# 짝 파일이 서로를 부르므로 여기서 `.js` 를 채워 준다.
|
|
for emitted in out.glob("*.js"):
|
|
emitted.write_text(
|
|
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
|
encoding="utf-8",
|
|
)
|
|
(out / "runner.mjs").write_text(_TS_RUNNER, encoding="utf-8")
|
|
payload = tmp_path / "input.json"
|
|
result = tmp_path / "output.json"
|
|
payload.write_text(
|
|
json.dumps(
|
|
{
|
|
"cases": [
|
|
{
|
|
"samples": case["samples"],
|
|
"design_elevation_m": case["design_elevation_m"],
|
|
"options": case["options"],
|
|
}
|
|
for case in CASES
|
|
],
|
|
"standard": STANDARD_CROSS_SECTION,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
subprocess.run( # noqa: S603
|
|
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
|
cwd=str(PROJECT_ROOT),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
return json.loads(result.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _diff(left, right, path: str = "") -> list[str]:
|
|
"""두 값이 갈린 자리를 경로와 함께 모은다 — 어느 항목이 틀렸는지 바로 보이게."""
|
|
if isinstance(left, dict) and isinstance(right, dict):
|
|
problems = []
|
|
for key in sorted(set(left) | set(right)):
|
|
if key not in left:
|
|
problems.append(f"{path}.{key}: 파이썬에 없음(TS={right[key]!r})")
|
|
elif key not in right:
|
|
problems.append(f"{path}.{key}: TS 에 없음(파이썬={left[key]!r})")
|
|
else:
|
|
problems.extend(_diff(left[key], right[key], f"{path}.{key}"))
|
|
return problems
|
|
if isinstance(left, list) and isinstance(right, list):
|
|
if len(left) != len(right):
|
|
return [f"{path}: 길이 {len(left)} vs {len(right)}"]
|
|
problems = []
|
|
for index, (one, other) in enumerate(zip(left, right, strict=True)):
|
|
problems.extend(_diff(one, other, f"{path}[{index}]"))
|
|
return problems
|
|
if isinstance(left, bool) or isinstance(right, bool):
|
|
return [] if left == right else [f"{path}: {left!r} vs {right!r}"]
|
|
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
|
|
if abs(float(left) - float(right)) <= _TOLERANCE:
|
|
return []
|
|
return [f"{path}: {left!r} vs {right!r}"]
|
|
return [] if left == right else [f"{path}: {left!r} vs {right!r}"]
|
|
|
|
|
|
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
|
def test_횡단_설계선과_단면적이_두_쪽에서_같다(tmp_path: Path) -> None:
|
|
expected = _python_results()
|
|
actual = _ts_results(tmp_path)
|
|
assert len(actual) == len(expected)
|
|
problems: list[str] = []
|
|
for case, left, right in zip(CASES, expected, actual, strict=True):
|
|
problems.extend(_diff(left, right, case["name"]))
|
|
assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40])
|
|
|
|
|
|
def test_사토장_갈래가_빈_값으로_통과하지_않는다() -> None:
|
|
"""사토장 측점이 0 ㎡ 면 대조가 아무것도 안 잠근다 — 값이 실제로 서는지 본다."""
|
|
spoil = [
|
|
result
|
|
for case, result in zip(CASES, _python_results(), strict=True)
|
|
if case["name"].startswith("사토장")
|
|
]
|
|
assert spoil, "사토장 사례가 있어야 한다"
|
|
for result in spoil:
|
|
assert result["spoil_fill_area_m2"] > 0
|
|
assert result["spoil_fill_replaced_fill_m2"] > 0
|
|
assert len(result["spoil_fill_line"]) >= 3
|
|
# 기울기를 눕히고 넓히면 더 담긴다.
|
|
assert spoil[1]["spoil_fill_area_m2"] > spoil[0]["spoil_fill_area_m2"]
|
|
|
|
|
|
def test_시험이_빈_결과로_통과하지_않는다() -> None:
|
|
"""면적이 실제로 나오는 경우가 섞여 있어야 대조가 뜻을 가진다."""
|
|
results = _python_results()
|
|
assert len(results) == len(CASES)
|
|
assert sum(1 for item in results if item["cut_area_m2"] > 0) >= 4
|
|
assert sum(1 for item in results if item["fill_area_m2"] > 0) >= 1
|
|
assert any(item.get("widening_left_m") or item.get("widening_right_m") for item in results)
|
|
assert any(item["cut_rock_area_m2"] > 0 for item in results)
|