Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-14 08:57:29 +09:00
10 changed files with 340 additions and 27 deletions
@@ -0,0 +1,118 @@
"""B05 시설 [저장]이 폼이 모르는 칸을 안 지우는가 (2026-09-14 브레인 판정).
⚠ 앞서 저장이 옵션을 통째로 갈아 끼워, 구조물 집계표·구조물도로 적은 칸(관 기슭막이 기초 등)이
B05 에서 그 시설을 한 번 더 저장하면 조용히 지워졌음 — 되돌릴 길이 없는 사고.
짝: `B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts` — 실제 TS 를 옮겨 돌림.
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
MERGE_TS = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Merge.ts"
FORM_TS = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts"
RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { FORM_OPTION_KEYS, mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge.js";
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = cases.map(([previous, next]) => mergeFacilityOptions(previous, next));
writeFileSync(process.argv[3], JSON.stringify({ out, keys: FORM_OPTION_KEYS }));
"""
PIPE_BEFORE = {
"facility": "pipe",
"options": {
"pipe_diameter_mm": 1000,
"inlet_type": "집수정",
"inlet_basin_form": "□형(기본형)",
"revet_foundation": "기초유", # 집계표로 적은 칸 — 폼에 없음
"inlet_basin_before_m": 1.5, # 횡단(B06)이 적는 칸 — 폼에 없음
},
}
# 폼이 다시 저장 — 유입구를 기슭막이로 바꿔 집수정 칸은 안 보냄
PIPE_FORM = {
"facility": "pipe",
"options": {
"pipe_diameter_mm": 800,
"inlet_type": "기슭막이",
"inlet_revet_form": "돌쌓기(찰)",
},
}
REVET_BEFORE = {"facility": "revetment", "options": {"side": "양쪽", "back_len_cm": "35"}}
TYPE_CHANGE = {"facility": "ford_pavement", "options": {"ford_width_m": 5}}
@pytest.fixture(scope="module")
def merged(tmp_path_factory) -> dict:
if not TSC.is_file():
pytest.skip("프론트엔드 의존성 미설치")
out = tmp_path_factory.mktemp("merge_js")
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(MERGE_TS),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(RUNNER, encoding="utf-8")
cases = [
[PIPE_BEFORE, PIPE_FORM],
[REVET_BEFORE, {"facility": "revetment", "options": {"side": "좌"}}],
[PIPE_BEFORE, TYPE_CHANGE],
[None, PIPE_FORM],
]
(out / "cases.json").write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(out / "cases.json"), str(out / "result.json")],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads((out / "result.json").read_text(encoding="utf-8"))
def test_폼이_모르는_칸은_남고_폼이_아는_칸은_폼대로(merged) -> None:
options = merged["out"][0]["options"]
assert options["revet_foundation"] == "기초유" # 집계표 값이 살아남음
assert options["inlet_basin_before_m"] == 1.5
assert options["pipe_diameter_mm"] == 800 # 폼 값이 이김
assert "inlet_basin_form" not in options # 폼이 아는 칸을 폼이 비움 → 지움
assert merged["out"][1]["options"] == {"side": "좌", "back_len_cm": "35"}
def test_시설_종류를_바꾸면_통째로_갈아_끼움(merged) -> None:
assert merged["out"][2] == TYPE_CHANGE
assert merged["out"][3] == PIPE_FORM
def test_폼이_적는_칸이_모두_목록에_있다(merged) -> None:
"""폼에 칸을 늘리고 목록에 안 넣으면 여기서 잡힘."""
source = FORM_TS.read_text(encoding="utf-8")
body = source[source.index("readOptions() {") :]
branches = re.split(r'current === "(\w+)"', body)[1:]
assert branches[::2] == ["pipe", "box_culvert", "ford_pavement", "ford_bridge", "revetment"]
for facility, block in zip(branches[::2], branches[1::2]):
written = set(re.findall(r'putNumber\(options, "(\w+)"', block))
written |= set(re.findall(r"options\.(\w+) =", block))
missing = written - set(merged["keys"][facility])
assert not missing, f"{facility}: 폼이 적는데 목록에 없음 {missing}"
@@ -0,0 +1,60 @@
"""등록부 칸 채우기 A2·A3 (2026-09-14) — 표가 읽는 칸이 등록부에 없어 영영 안 서던 자리.
A2 개거(점 배치) — 연장 칸 · A3 물넘이포장(관 지점 시설) — 두께·노폭 방향 길이 칸.
⚠ 옛 저장본(칸 없음)은 안 깨지고 **사유가 보임** — 「값이 없으면 표가 안 선다」가 아니라 「사유를 보인다」.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Structures_Repository import _validate_types # noqa: E402
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Pipe import facility_structures # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
def _ditch(**options) -> dict:
return build_table(
[{"structure_id": "d", "type_id": "open_ditch", "chainage_m": 10.0, "options": options}]
)["structures"][0]
def test_개거_연장_칸이_등록부에_서고_구조물_목록에_저장된다() -> None:
keys = {option.key for option in structure_type_map()["open_ditch"].options}
assert "length_m" in keys
item = StructureInstance(
type_id="open_ditch", placement="point", chainage_m=10.0, options={"length_m": 12}
)
_validate_types([item]) # 등록부에 없는 칸이면 여기서 거절됨
def test_개거_연장이_있으면_표가_서고_없으면_어디서_적는지_사유() -> None:
assert _ditch(length_m=12)["components"]
old = _ditch() # 옛 저장본 — 칸이 없음
assert not old["components"]
assert old["notes"][0].startswith("개거(겉도랑) 연장(m)이(가) 아직 입력되지 않았습니다")
def _ford(**options) -> dict:
point = {"facility": "ford_pavement", "chainage_m": 30.0, "options": options}
return build_table(facility_structures([point]))["structures"][0]
def test_물넘이포장_두께_길이_칸이_있으면_면적으로_선다() -> None:
keys = {option.key: option for option in structure_type_map()["ford_pavement"].options}
assert keys["thickness_cm"].phase == "detail" and keys["length_m"].phase == "detail"
row = _ford(ford_width_m=5, thickness_cm=20, length_m=8)
assert row["components"] and (row["billing_unit"], row["billing_quantity"]) == ("㎡", 40.0)
def test_물넘이포장_칸이_비면_무엇을_적을지_사유() -> None:
old = _ford(ford_width_m=5)
assert not old["components"] and "포장 두께(㎝)" in old["notes"][0]
no_length = _ford(ford_width_m=5, thickness_cm=20)
assert not no_length["components"] and "포장 길이" in no_length["notes"][0]