Files
Aislo/resources/tester/test_b06_cross_design_mirror.py
eomsangdonandClaude Opus 5 0ef32b5279 chore(tester): 시험을 resources/tester/ 로 옮김 — 창끼리 건너가게
⚠ **뿌리** — `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>
2026-09-09 17:12:30 +09:00

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)