"""PLAN 448(442-2 · 요구 269-1) — B05 페이지 설정 [저장] · [확정] 영구저장. - 병합 — stage 2 params 에 사용자 값만 얹음(`options` 칸끼리 · 상태 · STALE 안 건드림) - 정책 갱신 — 종단 경고 기준(최대 기울기 · 종단곡선 R · 평면 R)만 · 기하(base_pvi · 편집) 그대로 - 작업임도 30 → 20 해석 · params 엔 원값 - E1 — 노선 바꾼 저장 · B04 재확정 요청이 설계속도 · 최소고 강제 · 시작 측점을 옮김 - E2 — B06 재생성 지형 기본 = 특수(B05 기본) - E3 — 초기값 스냅샷이 stage 2 params 를 뜨고 [초기화]가 되돌림 · 옛 스냅샷은 설정 칸만 걷음 - 저장 차례 · 초안 내보내기 · 끝 측점 편집 — Node 헬퍼 `helper_442_2_page_settings.cjs` ./venv/Scripts/python.exe -m pytest resources/tester/test_442_2_page_settings.py -q """ from __future__ import annotations import asyncio import copy import json import subprocess from pathlib import Path from types import SimpleNamespace import numpy as np import pytest from B03_FileInput.B03_FileInput_Service_Chain import redesign_solve_request from B05_Profile import B05_Profile_PageSettings as page from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options from B05_Profile.B05_Profile_Engine_Grade_Profile import design_alignment_profile from B05_Profile.B05_Profile_Schema import PageSettingsRequest from B06_Section.B06_Section_Router_Regenerate_Options import _regeneration_grade_options from common_util import common_util_initial_snapshot as snapshot from common_util.common_util_workflow_state import merge_stage_params, merged_params HERE = Path(__file__).parent @pytest.fixture(scope="module") def out(): proc = subprocess.run( ["node", str(HERE / "helper_442_2_page_settings.cjs")], capture_output=True, text=True, encoding="utf-8", timeout=60, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) PARAMS = { "points": {"bp": {"x": 0, "y": 0}, "ep": {"x": 1, "y": 1}}, "options": {"grade_class": "trunk", "terrain_type": "special", "paved": False}, "station_interval_m": 20, "max_grade_pct": 13.0, "start_elevation_offset_m": 0.4, } # ── 병합 ───────────────────────────────────────────────────────────────────── def test_병합_options_칸끼리_나머지_갈음(): request = PageSettingsRequest( design_speed_kph=30, terrain_type="normal", max_grade_pct=None, min_vertical_radius_m=300, station_display={"station": 12.4, "cumulative": 240}, ) merged = merged_params(PARAMS, page.params_patch(request)) assert merged["options"] == { "grade_class": "trunk", "terrain_type": "normal", "paved": False, "design_speed_kph": 30, } assert merged["max_grade_pct"] is None # 비운 칸 = 법정값 assert merged["min_vertical_radius_m"] == 300 assert merged["station_display"] == {"station": 12.0, "cumulative": 240.0} assert merged["points"] == PARAMS["points"] and merged["start_elevation_offset_m"] == 0.4 assert PARAMS["options"]["terrain_type"] == "special" # 원본 안 바뀜 class _Cursor: def __init__(self, params): self.params = params self.sql: list[str] = [] async def execute(self, sql, args=None): self.sql.append(" ".join(sql.split())) if sql.strip().upper().startswith("UPDATE"): self.params = args[0] async def fetchone(self): return {"params": self.params} def test_merge_stage_params_상태_안_건드림(): cursor = _Cursor(json.dumps(PARAMS)) merged = asyncio.run(merge_stage_params(cursor, "p1", 2, {"options": {"design_speed_kph": 40}})) assert merged["options"]["design_speed_kph"] == 40 assert json.loads(cursor.params)["options"]["grade_class"] == "trunk" assert "FOR UPDATE" in cursor.sql[0] assert len(cursor.sql) == 2 and cursor.sql[1].startswith("UPDATE project_workflow_stages SET") assert all("state" not in sql.lower() and "projects" not in sql for sql in cursor.sql[1:]) def test_요청_검증(): with pytest.raises(ValueError): PageSettingsRequest(design_speed_kph=50) with pytest.raises(ValueError): PageSettingsRequest(terrain_type="flat") with pytest.raises(ValueError): PageSettingsRequest(max_grade_pct=0) with pytest.raises(ValueError): PageSettingsRequest(extra=1) def test_작업임도_30은_20으로_params_는_원값(): request = PageSettingsRequest(design_speed_kph=30, terrain_type="normal") params = merged_params({"options": {}}, page.params_patch(request)) assert params["options"]["design_speed_kph"] == 30 # 원값 저장(해석값 금지) work = page.grade_options_of("work", params) assert (work.design_speed_kph, work.max_grade_pct, work.min_vertical_radius_m) == (20, 9, 100) trunk = page.grade_options_of("main", params) # B02 main = 간선 assert (trunk.design_speed_kph, trunk.max_grade_pct, trunk.min_vertical_radius_m) == ( 30, 8, 250, ) typed = page.grade_options_of("main", {**params, "max_grade_pct": 11.0}) assert typed.max_grade_pct == 11.0 and typed.warnings # 법정 8 초과 = 경고(막지 않음) # ── 정책 갱신 ──────────────────────────────────────────────────────────────── def _longitudinal() -> dict: """앞 200 m 11 % 오르막 · 뒤 200 m 2 % — 특수 20(14 %)엔 위반 없음 · 일반 30(8 %)엔 위반.""" chainage = np.arange(0.0, 400.5, 1.0) ground = np.where(chainage <= 200, 100 + 0.11 * chainage, 122 + 0.02 * (chainage - 200)) longitudinal = { "samples": [ {"chainage_m": float(s), "elevation_m": float(z)} for s, z in zip(chainage, ground) ], "stations": [ {"station_id": f"s{i}", "chainage_m": float(s)} for i, s in enumerate(np.arange(0, 401, 20)) ], } options = resolve_grade_options("trunk", terrain_type="special", design_speed_kph=20) alignment, profile = design_alignment_profile(longitudinal, options) edits = {"station_offsets": {"100.000": 0.8}, "curve_radii": {}} longitudinal["profile_alignment"] = alignment longitudinal["design_profiles"] = [profile] from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile alignment, profile = rebuild_alignment_profile(longitudinal, edits) longitudinal["profile_alignment"] = alignment longitudinal["design_profiles"] = [profile] return longitudinal def test_정책_갱신_기하_그대로(): before = _longitudinal() after = copy.deepcopy(before) strict = resolve_grade_options("trunk", terrain_type="normal", design_speed_kph=30) alignment, summary = page.retarget_longitudinal(after, strict) policy = alignment["policy"] assert (policy["max_grade_pct"], policy["min_vertical_radius_m"]) == (8, 250) assert policy["min_plan_radius_m"] == 30 # 30 km/h 일반지형 criteria = after["design_profiles"][0]["criteria"] assert (criteria["design_speed_kph"], criteria["terrain_type"]) == (30, "normal") assert not before["profile_alignment"]["violations"] assert after["profile_alignment"]["violations"] # 같은 선 · 엄한 기준 = 위반 뜸 assert summary["id"] == after["design_profiles"][0]["id"] # 기하 그대로 — 자동 선형 · 편집 · 계획고 표본 assert alignment["base_pvi"] == before["profile_alignment"]["base_pvi"] assert alignment["edits"] == before["profile_alignment"]["edits"] z0 = [s["elevation_m"] for s in before["profile_alignment"]["samples"]] z1 = [s["elevation_m"] for s in alignment["samples"]] assert np.allclose(z0, z1) def test_retarget_file_선형_없으면_그대로(tmp_path): options = resolve_grade_options("trunk", terrain_type="normal") rel = "B06_Section/longitudinal/longitudinal.json" path = tmp_path / rel path.parent.mkdir(parents=True) path.write_text(json.dumps({"samples": []}), encoding="utf-8") assert page.retarget_file(tmp_path, rel, options) is None assert json.loads(path.read_text(encoding="utf-8")) == {"samples": []} assert page.retarget_file(tmp_path, "nope.json", options) is None with pytest.raises(ValueError): page.retarget_file(tmp_path, "../outside.json", options) path.write_text(json.dumps(_longitudinal()), encoding="utf-8") summary = page.retarget_file(tmp_path, rel, options) stored = json.loads(path.read_text(encoding="utf-8")) assert summary is not None and stored["profile_alignment"]["policy"]["max_grade_pct"] == 9 # ── E1 · E2 ────────────────────────────────────────────────────────────────── def test_E1_재설계_요청이_설정을_옮김(): params = { **PARAMS, "options": {**PARAMS["options"], "design_speed_kph": 30}, "enforce_pipe_clearance": True, "station_display": {"station": 12, "cumulative": 240}, } request = redesign_solve_request(params, {"source_filter": "csf"}, 5) assert request.design_speed_kph == 30 assert request.enforce_pipe_clearance is True assert request.station_display == {"station": 12, "cumulative": 240} assert request.terrain_type == "special" def test_E2_B06_재생성_지형_기본_특수(): no_terrain = _regeneration_grade_options({"options": {"grade_class": "trunk"}}) assert no_terrain.terrain_type == "special" and no_terrain.max_grade_pct == 14 saved = _regeneration_grade_options( {"options": {"grade_class": "trunk", "terrain_type": "normal", "design_speed_kph": 30}} ) assert (saved.terrain_type, saved.max_grade_pct) == ("normal", 8) def test_노선_추천_설정은_options_에서_읽음(monkeypatch): from B05_Profile import B05_Profile_Router_Replan as replan stage = { "stage_no": 2, "params": {"options": {"design_speed_kph": 30, "terrain_type": "normal"}}, } class _Cur: async def __aenter__(self): return self async def __aexit__(self, *_): return False async def execute(self, *_): return None async def fetchone(self): return ("main",) class _Conn: def cursor(self, *_): return _Cur() class _Acquire: async def __aenter__(self): return _Conn() async def __aexit__(self, *_): return False monkeypatch.setattr(replan, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire)) async def fake_state(*_): return {"stages": [stage]} import common_util.common_util_workflow_state as workflow monkeypatch.setattr(workflow, "get_workflow_state", fake_state) assert asyncio.run(replan._road_settings("p1")) == ("main", 30, "normal") # ── E3 스냅샷 ──────────────────────────────────────────────────────────────── class _SnapCursor: def __init__(self, state): self.state = state async def __aenter__(self): return self async def __aexit__(self, *_): return False async def execute(self, sql, args=None): self.state["sql"].append(sql) if sql.strip().upper().startswith("UPDATE"): self.state["params"] = args[0] async def fetchone(self): return (self.state["params"],) class _SnapConn: def __init__(self, state): self.state = state def cursor(self, *_): return _SnapCursor(self.state) def test_E3_스냅샷_stage2_왕복(): initial = {**PARAMS, "options": {**PARAMS["options"], "design_speed_kph": 20}} state = {"params": json.dumps(initial), "sql": []} dumped = asyncio.run(snapshot._stage2_params(_SnapConn(state), "p1")) assert dumped == initial state["params"] = json.dumps( {**initial, "max_grade_pct": 7, "options": {"design_speed_kph": 40}} ) asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"stage2_params": dumped})) assert json.loads(state["params"]) == initial def test_E3_옛_스냅샷은_설정_칸만_걷음(): edited = { **PARAMS, "options": {**PARAMS["options"], "design_speed_kph": 40, "terrain_type": "normal"}, "min_vertical_radius_m": 500, "enforce_pipe_clearance": True, "station_display": {"station": 3, "cumulative": 60}, } state = {"params": json.dumps(edited), "sql": []} asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"routes": []})) restored = json.loads(state["params"]) assert restored["options"] == {"grade_class": "trunk", "paved": False} for key in page.PAGE_TOP_KEYS: assert key not in restored assert restored["points"] == PARAMS["points"] and restored["start_elevation_offset_m"] == 0.4 def test_E3_뜰_params_없으면_안_씀(): state = {"params": None, "sql": []} asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"stage2_params": None})) assert not any(sql.strip().upper().startswith("UPDATE") for sql in state["sql"]) # ── 화면(Node) ─────────────────────────────────────────────────────────────── def test_저장_차례_settings_는_노선_뒤_관_앞(out): order = out["order"] assert order.index("route") + 1 == order.index("settings") < order.index("alignment") def test_노선_바꾼_저장에도_settings_기본_창구(out): assert out["routeSaveDone"] is True assert out["routeSaveCalls"] == ["route", "settings:p1"] # 화면 몫(own) 아님 · 기본 창구 def test_초안_내보내기(out): assert out["putsWithoutDraft"] == 0 assert out["boom"] == "서버 500" and out["draftAfterBoom"] == out["draft"] assert out["draftAfterOk"] is None and out["puts"] == 2 params = out["latestAfterOk"]["route_params"] assert params["options"] == { "grade_class": "trunk", "design_speed_kph": 30, "terrain_type": "normal", } assert params["max_grade_pct"] is None and params["min_vertical_radius_m"] == 300 assert params["station_display"] == {"station": 12, "cumulative": 240} def test_초안_되살림_값(out): restore = out["restoreFromDraft"] assert restore["designSpeed"] == 30 and restore["maxGradePct"] is None assert restore["stationDisplay"] == {"station": 12, "cumulative": 240} def test_시종점_조정은_끝_측점_계획고_편집(out): assert out["endsEmpty"] == {"start": 0, "end": 0} assert out["endsRaised"] == {"start": 1.25, "end": 0} assert out["endsBoth"] == {"start": 1.25, "end": -0.5} assert out["planStart"] == pytest.approx(101.25) and out["planEnd"] == pytest.approx(107.5) assert out["backToZero"]["start"] == 0 def test_시작_측점은_열쇠_다른_페이지에_안_섞임(): root = HERE.parents[1] hits = subprocess.run( ["git", "grep", "-l", "station_display", "--", "*.py", "*.ts"], capture_output=True, text=True, cwd=root, encoding="utf-8", ).stdout.split() allowed = { "B03_FileInput/B03_FileInput_Service_Chain.py", # E1 옮김 "B05_Profile/B05_Profile_Api_Fetch.ts", "B05_Profile/B05_Profile_Api_PageSettings.ts", "B05_Profile/B05_Profile_PageSettings.py", "B05_Profile/B05_Profile_Router.py", "B05_Profile/B05_Profile_Schema.py", "B05_Profile/B05_Profile_UI_Page_Latest.ts", } assert {hit for hit in hits if not hit.startswith("resources/")} <= allowed def test_저장_정책을_다시_읽어도_평면_R_유지(): from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy policy = AlignmentPolicy.from_dict({"min_plan_radius_m": 30, "max_grade_pct": 8}) assert policy.min_plan_radius_m == 30 and policy.hairpin_min_radius_m == 10 assert AlignmentPolicy.from_dict({}).min_plan_radius_m == 0 # 옛 정책 = 판정 안 함 그대로