Files
Aislo/resources/tester/test_masshaul_stone_deduction.py
T
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

424 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""채집석 공제 — 사토에서 한 번만, 실어 내는 몫부터 뺀다 (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)