"""채집석 공제 — 사토에서 한 번만, 실어 내는 몫부터 뺀다 (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)