"""사토장(유용토운반작업장) 성토 단면 — 파이썬·TS 짝이 같은 값을 내는지 (2026-09-09). 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽이 갈리면 「그림은 이런데 수량은 저렇다」가 된다. ⚠ 폭의 시작점이 **노면 끝**이라는 것과, 폭 상한이 **지반 샘플이 있는 데까지**라는 것을 함께 잠근다. 둘 다 사용자 확정이다. """ from __future__ import annotations 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 common_util.common_util_spoil_fill import ( # noqa: E402 solve_spoil_width_m, spoil_fill_section, spoil_max_width_m, ) TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" #: 왼쪽으로 완만히 내려가는 지반. 노면 끝은 (2.0, 100.0). GROUND = [(-10.0, 98.0), (0.0, 100.0), (5.0, 99.5), (10.0, 98.0), (20.0, 95.0), (30.0, 92.0)] _RUNNER = """ import { writeFileSync } from "node:fs"; import { spoilFillSection, solveSpoilWidthM, spoilMaxWidthM } from "./common_util_spoil_fill.js"; const ground = %s.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })); const base = { ground, startOffsetM: 2.0, startElevationM: 100.0, side: "left" }; const out = { maxWidth: spoilMaxWidthM(base), widths: [0, 2, 4, 8, 40].map((widthM) => { const s = spoilFillSection({ ...base, widthM, slopeRatioN: 1.5 }); return { area: s.area_m2, toe: s.toeOffsetM, unclosed: s.unclosed, line: s.line.map((p) => [p.offset_m, p.elevation_m]) }; }), right: (() => { const s = spoilFillSection({ ...base, side: "right", startOffsetM: -2.0, widthM: 4, slopeRatioN: 1.5 }); return { area: s.area_m2, toe: s.toeOffsetM, line: s.line.map((p) => [p.offset_m, p.elevation_m]) }; })(), solved: [10, 40, 100000].map((target) => { const r = solveSpoilWidthM({ ...base, slopeRatioN: 1.5 }, target); return { widthM: r.widthM, area: r.section.area_m2 }; }), }; writeFileSync(process.argv[2], JSON.stringify(out)); """ % json.dumps(GROUND) def _ts_values(tmp_path: Path) -> dict: out = tmp_path / "js" subprocess.run( # noqa: S603 — 고정 실행 파일 [ "node", str(TSC), str(PROJECT_ROOT / "common_util" / "common_util_spoil_fill.ts"), "--outDir", str(out), "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", ], cwd=str(PROJECT_ROOT), check=True, capture_output=True, ) for emitted in out.glob("*.js"): text = emitted.read_text(encoding="utf-8") emitted.write_text( re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text), encoding="utf-8", ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") result = tmp_path / "out.json" subprocess.run( # noqa: S603 ["node", str(out / "runner.mjs"), str(result)], cwd=str(PROJECT_ROOT), check=True, capture_output=True, ) return json.loads(result.read_text(encoding="utf-8")) @pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") def test_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None: ts = _ts_values(tmp_path) assert ts["maxWidth"] == pytest.approx(spoil_max_width_m(GROUND, 2.0, "left")) for index, width in enumerate((0, 2, 4, 8, 40)): py = spoil_fill_section(GROUND, 2.0, 100.0, "left", width, 1.5) got = ts["widths"][index] assert got["area"] == pytest.approx(py.area_m2), width assert got["toe"] == pytest.approx(py.toe_offset_m), width assert got["unclosed"] == py.unclosed, width assert [tuple(point) for point in got["line"]] == py.line, width right = spoil_fill_section(GROUND, -2.0, 100.0, "right", 4, 1.5) assert ts["right"]["area"] == pytest.approx(right.area_m2) assert [tuple(point) for point in ts["right"]["line"]] == right.line for index, target in enumerate((10, 40, 100000)): width, section = solve_spoil_width_m(GROUND, 2.0, 100.0, "left", 1.5, target) assert ts["solved"][index]["widthM"] == pytest.approx(width), target assert ts["solved"][index]["area"] == pytest.approx(section.area_m2), target def test_폭은_노면_끝에서_잰다() -> None: """시작점이 노면 끝이라 **그 구간 노견도 이 성토 안에 든다**(사용자 확정).""" section = spoil_fill_section(GROUND, 2.0, 100.0, "left", 4.0, 1.5) assert section.line[0] == (2.0, 100.0) assert section.line[1] == (6.0, 100.0) # 평상은 노면 끝 높이 그대로 assert section.toe_offset_m > 6.0 def test_넓힐수록_많이_담긴다() -> None: areas = [spoil_fill_section(GROUND, 2.0, 100.0, "left", w, 1.5).area_m2 for w in (0, 2, 4, 8)] assert areas == sorted(areas) assert areas[0] < areas[-1] def test_지반_샘플_밖으로는_안_넓힌다() -> None: """샘플 밖은 지반을 모른다 — 넓히면 근거 없는 부피가 된다.""" limit = spoil_max_width_m(GROUND, 2.0, "left") assert limit == pytest.approx(28.0) wide = spoil_fill_section(GROUND, 2.0, 100.0, "left", 100.0, 1.5) assert wide.toe_offset_m <= 30.0 assert wide.unclosed is True # 지반을 못 만나고 잘렸음을 드러낸다 def test_담을_수_없는_양은_상한_폭을_돌려준다() -> None: """임의로 더 넓히지 않는다 — 못 담는 몫은 부르는 쪽이 「남은 사토」로 드러낸다.""" width, section = solve_spoil_width_m(GROUND, 2.0, 100.0, "left", 1.5, 1e6) assert width == pytest.approx(spoil_max_width_m(GROUND, 2.0, "left")) assert section.area_m2 < 1e6 def test_기울기가_완만하면_더_담긴다() -> None: steep = spoil_fill_section(GROUND, 2.0, 100.0, "left", 4.0, 1.0) gentle = spoil_fill_section(GROUND, 2.0, 100.0, "left", 4.0, 2.0) assert gentle.area_m2 > steep.area_m2