⚠ **뿌리** — `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>
424 lines
18 KiB
Python
424 lines
18 KiB
Python
"""채집석 공제 — 사토에서 한 번만, 실어 내는 몫부터 뺀다 (2026-09-09 사용자 확정).
|
||
|
||
채집석 공제는 사토에서 한 번만 뺀다.
|
||
B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
|
||
빼는 자리는 유토곡선의 사토뿐이다 —
|
||
실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
|
||
|
||
TS 코드를 **실제로 돌려** 확인한다 — 파이썬 짝이 없는 자리라(브라우저·서버 모두 이 TS 를
|
||
그대로 실행) 소스 문자열을 훑는 것으로는 셈이 맞는지 알 수 없다.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||
|
||
_RUNNER = """
|
||
import { readFileSync, writeFileSync } from "node:fs";
|
||
import { computeHaulPlan } from "./common_util_mass_haul_balance.js";
|
||
|
||
const [inputPath, outputPath] = process.argv.slice(2);
|
||
const input = JSON.parse(readFileSync(inputPath, "utf8"));
|
||
const out = [];
|
||
for (const item of input.cases) {
|
||
const plan = computeHaulPlan(input.result, input.limits, {
|
||
collected_stone_deduction_m3: item.deduction ?? null,
|
||
collected_stone_by_ground_m3: item.stoneByGround ?? null,
|
||
collected_stone_ground_unknown_m3: item.stoneUnknown ?? null,
|
||
structure_spoil_m3: item.spoil ?? null,
|
||
structure_spoil_points: item.points ?? null,
|
||
conversion: item.conversion ?? null,
|
||
});
|
||
out.push(
|
||
plan === null
|
||
? null
|
||
: {
|
||
spoil_m3: plan.spoil_m3,
|
||
natural_spoil_m3: plan.natural_spoil_m3,
|
||
borrow_m3: plan.borrow_m3,
|
||
deduction: plan.collected_stone_deduction_m3,
|
||
deducted: plan.collected_stone_deducted_m3,
|
||
spoilIn: plan.structure_spoil_m3,
|
||
spoilAdded: plan.structure_spoil_added_m3,
|
||
residuals: plan.residuals.map((r) => ({
|
||
kind: r.kind,
|
||
volume_m3: r.volume_m3,
|
||
natural_m3: r.natural_m3,
|
||
ea_m3: r.ea_m3,
|
||
rr_m3: r.rr_m3,
|
||
br_m3: r.br_m3,
|
||
})),
|
||
},
|
||
);
|
||
}
|
||
writeFileSync(outputPath, JSON.stringify(out));
|
||
"""
|
||
|
||
|
||
def _mass_haul_result() -> dict:
|
||
"""절토가 앞, 성토가 뒤인 짧은 노선 — 다 못 쓴 흙이 사토로 남는다."""
|
||
points = []
|
||
cumulative = 0.0
|
||
for index in range(11):
|
||
cut = 50.0 if index and index <= 5 else 0.0
|
||
fill = 20.0 if index > 5 else 0.0
|
||
net = cut - fill
|
||
cumulative += net
|
||
points.append(
|
||
{
|
||
"station_id": f"S{index:03d}",
|
||
"chainage_m": index * 20.0,
|
||
"net_volume_m3": net,
|
||
"cumulative_volume_m3": cumulative,
|
||
"cut_soil_m3": cut,
|
||
"cut_rock_m3": 0.0,
|
||
"cut_rr_m3": 0.0,
|
||
"cut_br_m3": 0.0,
|
||
"cut_compacted_m3": cut,
|
||
"fill_m3": fill,
|
||
"natural_spoil": False,
|
||
"net_area_m2": net / 20.0,
|
||
}
|
||
)
|
||
return {
|
||
"points": points,
|
||
"cut_natural_m3": {"ea": 250.0, "rr": 0.0, "br": 0.0},
|
||
"cut_compacted_m3": 250.0,
|
||
"fill_compacted_m3": 100.0,
|
||
"final_cumulative_m3": cumulative,
|
||
"surplus_m3": max(cumulative, 0.0),
|
||
"shortage_m3": 0.0,
|
||
"min_cumulative_m3": 0.0,
|
||
"max_cumulative_m3": 250.0,
|
||
"conversion": {"soil": 1.0, "ripping_rock": 1.0, "blasting_rock": 1.0},
|
||
}
|
||
|
||
|
||
def _run(tmp_path: Path, cases: list[dict]) -> list[dict | None]:
|
||
out = tmp_path / "js"
|
||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||
[
|
||
"node",
|
||
str(TSC),
|
||
str(PROJECT_ROOT / "common_util" / "common_util_mass_haul_balance.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"):
|
||
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")
|
||
payload = tmp_path / "input.json"
|
||
result = tmp_path / "output.json"
|
||
payload.write_text(
|
||
json.dumps({"result": _mass_haul_result(), "limits": None, "cases": cases}),
|
||
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"))
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_공제가_사토를_줄이고_안_온_값과_0을_가른다(tmp_path: Path) -> None:
|
||
none_plan, zero_plan, some_plan = _run(tmp_path, [{}, {"deduction": 0.0}, {"deduction": 30.0}])
|
||
assert none_plan is not None and zero_plan is not None and some_plan is not None
|
||
|
||
# ① 「아직 안 옴(None)」과 「공제 없음(0)」은 결과가 같되 **표시가 다르다**.
|
||
assert none_plan["deduction"] is None
|
||
assert zero_plan["deduction"] == 0
|
||
assert none_plan["spoil_m3"] == zero_plan["spoil_m3"]
|
||
|
||
# ② 공제한 만큼 사토가 줄어든다.
|
||
assert some_plan["deducted"] == pytest.approx(30.0)
|
||
assert some_plan["spoil_m3"] == pytest.approx(zero_plan["spoil_m3"] - 30.0)
|
||
|
||
# ③ 잔량 자체가 줄어야 운반 물량이 따라간다 — 총량만 줄이면 안 된다.
|
||
before = sum(r["volume_m3"] for r in zero_plan["residuals"] if r["kind"] == "spoil")
|
||
after = sum(r["volume_m3"] for r in some_plan["residuals"] if r["kind"] == "spoil")
|
||
assert after == pytest.approx(before - 30.0)
|
||
|
||
# ④ 토취는 건드리지 않는다.
|
||
assert some_plan["borrow_m3"] == zero_plan["borrow_m3"]
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_사토보다_큰_공제는_사토까지만_뺀다(tmp_path: Path) -> None:
|
||
(zero_plan,) = _run(tmp_path, [{"deduction": 0.0}])
|
||
huge = zero_plan["spoil_m3"] * 10
|
||
(plan,) = _run(tmp_path, [{"deduction": huge}])
|
||
|
||
assert plan["spoil_m3"] == pytest.approx(0.0, abs=1e-6)
|
||
assert plan["deducted"] == pytest.approx(zero_plan["spoil_m3"])
|
||
assert plan["deducted"] < huge # 받은 값보다 작게 뺐다는 것이 드러난다
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_자연방토는_나중에_깎인다(tmp_path: Path) -> None:
|
||
"""실어 내는 몫이 남아 있는 동안에는 자연방토가 줄지 않는다."""
|
||
(zero_plan,) = _run(tmp_path, [{"deduction": 0.0}])
|
||
haul_out = zero_plan["spoil_m3"] - zero_plan["natural_spoil_m3"]
|
||
if haul_out <= 0:
|
||
pytest.skip("이 표본은 사토가 전부 자연방토라 순서를 못 본다")
|
||
(plan,) = _run(tmp_path, [{"deduction": haul_out / 2.0}])
|
||
assert plan["natural_spoil_m3"] == pytest.approx(zero_plan["natural_spoil_m3"])
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_구조물_잔토는_사토에_더해지고_잔량도_함께_는다(tmp_path: Path) -> None:
|
||
"""⚠ 공제는 빼고 이것은 **더한다**. 총량만 늘리면 운반이 안 는다."""
|
||
zero_plan, spoil_plan = _run(tmp_path, [{}, {"spoil": 40.0}])
|
||
assert zero_plan is not None and spoil_plan is not None
|
||
|
||
assert zero_plan["spoilIn"] is None # 「아직 안 옴」
|
||
assert spoil_plan["spoilIn"] == pytest.approx(40.0)
|
||
assert spoil_plan["spoilAdded"] == pytest.approx(40.0)
|
||
assert spoil_plan["spoil_m3"] == pytest.approx(zero_plan["spoil_m3"] + 40.0)
|
||
|
||
before = sum(r["volume_m3"] for r in zero_plan["residuals"] if r["kind"] == "spoil")
|
||
after = sum(r["volume_m3"] for r in spoil_plan["residuals"] if r["kind"] == "spoil")
|
||
assert after == pytest.approx(before + 40.0)
|
||
|
||
# 자연방토는 안 늘린다 — 구조물 잔토는 실어 내는 흙이다.
|
||
assert spoil_plan["natural_spoil_m3"] == pytest.approx(zero_plan["natural_spoil_m3"])
|
||
# 토취는 건드리지 않는다.
|
||
assert spoil_plan["borrow_m3"] == pytest.approx(zero_plan["borrow_m3"])
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_공제와_잔토가_함께_와도_한_번씩만_먹는다(tmp_path: Path) -> None:
|
||
zero_plan, both = _run(tmp_path, [{}, {"deduction": 10.0, "spoil": 40.0}])
|
||
assert both["deducted"] == pytest.approx(10.0)
|
||
assert both["spoilAdded"] == pytest.approx(40.0)
|
||
assert both["spoil_m3"] == pytest.approx(zero_plan["spoil_m3"] - 10.0 + 40.0)
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_담을_사토가_없으면_사토를_새로_세운다(tmp_path: Path) -> None:
|
||
"""⚠ 물량이 사라지면 안 된다 — 파낸 흙은 어디로든 간다(2026-09-09 확정 ㉰).
|
||
|
||
토취를 줄이는 길은 「그 잔토를 성토재로 쓸 수 있다」는 근거가 있어야 하므로
|
||
지금은 **내보내는 쪽(안전측)**으로 둔다.
|
||
"""
|
||
(plan,) = _run(
|
||
tmp_path,
|
||
[{"spoil": 25.0, "points": [{"chainage_m": 500.0, "spoil_m3": 25.0}]}],
|
||
)
|
||
assert plan is not None
|
||
assert plan["spoilAdded"] == pytest.approx(25.0)
|
||
spoils = [r for r in plan["residuals"] if r["kind"] == "spoil"]
|
||
assert sum(r["volume_m3"] for r in spoils) >= 25.0 - 1e-6
|
||
# 새로 선 사토는 **실어 내는 몫**이다 — 자연방토로 눅이지 않는다.
|
||
assert all(r["natural_m3"] == 0 for r in spoils if r["volume_m3"] == pytest.approx(25.0))
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_잔토를_더한_뒤에_공제를_뺀다(tmp_path: Path) -> None:
|
||
"""⚠ 순서가 뜻을 가른다 — 사토가 0 인 노선에서 반대 순서면 공제가 영영 안 걸린다.
|
||
|
||
실측(두 창): 사토 0 인 노선에서 잔토 126.63 이 얹혔는데 공제는 0 이었다.
|
||
「더하고 빼기」로 두면 126.63 − 64.75 = 61.88 이 된다.
|
||
"""
|
||
base, plan = _run(
|
||
tmp_path,
|
||
[
|
||
{},
|
||
{
|
||
"spoil": 100.0,
|
||
"points": [{"chainage_m": 60.0, "spoil_m3": 100.0}],
|
||
"deduction": 40.0,
|
||
},
|
||
],
|
||
)
|
||
assert base is not None and plan is not None
|
||
assert plan["spoilAdded"] == pytest.approx(100.0)
|
||
assert plan["deducted"] == pytest.approx(40.0) # 잔토 덕에 뺄 대상이 생겼다
|
||
# 더하고 뺀 결과가 그대로 남는다.
|
||
assert plan["spoil_m3"] == pytest.approx(base["spoil_m3"] + 100.0 - 40.0)
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_터파기_토질이_오면_그_갈래로_담는다(tmp_path: Path) -> None:
|
||
"""B08 이 `design.ground_type` 으로 이미 판정한 값을 **이어받는다** — 새 근거가 아니다.
|
||
|
||
⚠ 못 고른 구조물은 토질이 안 실려 오고, 그 몫은 「지반 모름」으로 남는다
|
||
(`ground_unknown_m3` 로 드러난다).
|
||
"""
|
||
base, plan = _run(
|
||
tmp_path,
|
||
[
|
||
{},
|
||
{
|
||
"spoil": 60.0,
|
||
"points": [
|
||
# B08 이 보내는 이름(`ground_type`)과 옛 이름(`ground`) 둘 다 받는다.
|
||
{"chainage_m": 500.0, "spoil_m3": 20.0, "ground_type": "ripping_rock"},
|
||
{"chainage_m": 520.0, "spoil_m3": 25.0, "ground": "토사"},
|
||
{"chainage_m": 540.0, "spoil_m3": 15.0}, # 못 고른 것
|
||
],
|
||
},
|
||
],
|
||
)
|
||
assert base is not None and plan is not None
|
||
|
||
def bucket(rows: list[dict], key: str) -> float:
|
||
return sum(r[key] for r in rows if r["kind"] == "spoil")
|
||
|
||
before, after = base["residuals"], plan["residuals"]
|
||
assert plan["spoilAdded"] == pytest.approx(60.0)
|
||
# 갈래가 온 몫은 **그 갈래로** 늘어난다.
|
||
assert bucket(after, "rr_m3") - bucket(before, "rr_m3") == pytest.approx(20.0)
|
||
assert bucket(after, "ea_m3") - bucket(before, "ea_m3") == pytest.approx(25.0)
|
||
assert bucket(after, "br_m3") == pytest.approx(bucket(before, "br_m3"))
|
||
# 못 고른 15 는 어느 갈래에도 안 들어간다 — 「모름」으로 남는다.
|
||
unknown_before = bucket(before, "volume_m3") - (
|
||
bucket(before, "ea_m3") + bucket(before, "rr_m3") + bucket(before, "br_m3")
|
||
)
|
||
unknown_after = bucket(after, "volume_m3") - (
|
||
bucket(after, "ea_m3") + bucket(after, "rr_m3") + bucket(after, "br_m3")
|
||
)
|
||
assert unknown_after - unknown_before == pytest.approx(15.0, abs=1e-6)
|
||
|
||
|
||
# ── 구조물 잔토의 **상태** — 자연상태로 와서 다짐상태 곡선에 얹힌다 (2026-09-09) ──────
|
||
# B08 이 보내는 잔토는 터파기 제자리 기하 부피라 **자연상태**(`volume_basis: "natural"`)이고
|
||
# 유토곡선은 **다짐상태**다. 담기 전에 ×C 하지 않으면 상태가 다른 두 부피를 섞는 것이 된다.
|
||
_CONVERSION = {
|
||
"soil": {"loose": 1.25, "compacted": 0.90},
|
||
"ripping_rock": {"loose": 1.35, "compacted": 1.15},
|
||
"blasting_rock": {"loose": 1.60, "compacted": 1.30},
|
||
}
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_잔토는_다짐으로_바꿔_담는다(tmp_path: Path) -> None:
|
||
points = [{"chainage_m": 60.0, "spoil_m3": 100.0, "ground_type": "soil"}]
|
||
plain, converted = _run(
|
||
tmp_path,
|
||
[
|
||
{"points": points},
|
||
{"points": points, "conversion": _CONVERSION},
|
||
],
|
||
)
|
||
# 환산 없이 담으면 자연상태 100 이 그대로 들어간다.
|
||
assert plain["spoilAdded"] == pytest.approx(100.0)
|
||
# 계수가 오면 ×C — 토사 0.90 이라 다짐 90 으로 담긴다.
|
||
assert converted["spoilAdded"] == pytest.approx(90.0)
|
||
# ⚠ 왕복이 맞아야 한다 — B08 이 내보낼 때 ÷C 하면 원래 자연상태 100 이 돌아온다.
|
||
assert converted["spoilAdded"] / _CONVERSION["soil"]["compacted"] == pytest.approx(100.0)
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_갈래를_모르면_환산하지_않는다(tmp_path: Path) -> None:
|
||
"""계수가 없는 몫을 토사로 눅이면 근거 없이 금액이 움직인다."""
|
||
base, plan = _run(
|
||
tmp_path,
|
||
[
|
||
{"conversion": _CONVERSION},
|
||
{
|
||
"points": [{"chainage_m": 60.0, "spoil_m3": 100.0, "ground_type": None}],
|
||
"conversion": _CONVERSION,
|
||
},
|
||
],
|
||
)
|
||
assert plan["spoilAdded"] == pytest.approx(100.0)
|
||
|
||
def buckets(item: dict) -> float:
|
||
return sum(
|
||
r["ea_m3"] + r["rr_m3"] + r["br_m3"] for r in item["residuals"] if r["kind"] == "spoil"
|
||
)
|
||
|
||
# 갈래 칸은 그대로이고 총량만 는다 ⇒ 그 차이가 「지반 모름」으로 드러난다.
|
||
assert buckets(plan) == pytest.approx(buckets(base))
|
||
assert plan["spoil_m3"] == pytest.approx(base["spoil_m3"] + 100.0)
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_갈래마다_계수가_다르다(tmp_path: Path) -> None:
|
||
(plan,) = _run(
|
||
tmp_path,
|
||
[
|
||
{
|
||
"points": [
|
||
{"chainage_m": 60.0, "spoil_m3": 100.0, "ground_type": "ripping_rock"},
|
||
{"chainage_m": 80.0, "spoil_m3": 100.0, "ground_type": "blasting_rock"},
|
||
],
|
||
"conversion": _CONVERSION,
|
||
}
|
||
],
|
||
)
|
||
assert plan["spoilAdded"] == pytest.approx(115.0 + 130.0)
|
||
|
||
|
||
# ── 채집석의 **축** — 벽 입적(자연)을 다짐 곡선에서 빼려면 ×C (2026-09-09 세 창 확정) ──
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_채집석은_갈래마다_C를_곱해_뺀다(tmp_path: Path) -> None:
|
||
base, plain, converted = _run(
|
||
tmp_path,
|
||
[
|
||
{"deduction": 0.0},
|
||
{"deduction": 100.0, "stoneByGround": {"ripping_rock": 100.0}},
|
||
{
|
||
"deduction": 100.0,
|
||
"stoneByGround": {"ripping_rock": 100.0},
|
||
"conversion": _CONVERSION,
|
||
},
|
||
],
|
||
)
|
||
# 계수가 안 오면 종전처럼 그대로 뺀다.
|
||
assert plain["spoil_m3"] == pytest.approx(base["spoil_m3"] - 100.0)
|
||
# 리핑암 C=1.15 ⇒ 다짐 축에서는 115 을 빼야 같은 양이다.
|
||
assert converted["deducted"] == pytest.approx(115.0)
|
||
assert converted["spoil_m3"] == pytest.approx(base["spoil_m3"] - 115.0)
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_갈래_모르는_채집석은_환산하지_않는다(tmp_path: Path) -> None:
|
||
(plan,) = _run(
|
||
tmp_path,
|
||
[
|
||
{
|
||
"deduction": 100.0,
|
||
"stoneByGround": {"ripping_rock": 40.0},
|
||
"stoneUnknown": 60.0,
|
||
"conversion": _CONVERSION,
|
||
}
|
||
],
|
||
)
|
||
# 40×1.15 = 46 (환산) + 60 (그대로) = 106
|
||
assert plan["deducted"] == pytest.approx(106.0)
|
||
|
||
|
||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||
def test_갈래가_안_오면_총량으로_되돌아간다(tmp_path: Path) -> None:
|
||
"""값이 안 오는 것과 0 은 다르다 — 갈래가 없으면 종전 동작."""
|
||
(plan,) = _run(tmp_path, [{"deduction": 30.0, "conversion": _CONVERSION}])
|
||
assert plan["deducted"] == pytest.approx(30.0)
|