diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index c8f068986..60cd2b720 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -209,6 +209,8 @@ export const STATE_REGISTRY = { /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). * 옛 키(프로젝트 없는 `b05-profile-alignment-draft:{노선}`)는 안 받음 — 다른 프로젝트 초안이 섞임(387-1). */ "alignment-draft": { bucket: "draft", scope: "route" }, + /** B05 페이지 설정(448) — 설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점. [저장]의 `settings` 차례가 내보냄. */ + pagesettings: { bucket: "draft", scope: "project" }, /** 노선 초안(344-4) — 노드 · 곡선 · 반경 · 스냅 · 지형 · rev(`RouteDraft` · `design_pipeline_types`). * 노선 번호는 [저장]마다 새로 나므로 project 범위 · 가게 키 `routeDraft`. */ "route-draft": { bucket: "draft", scope: "project" }, diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 995236acc..ac9480145 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -46,6 +46,11 @@ import { toAlignmentBase, } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { + endOffsetsOf, + withEndOffset, + type ProfileEnd, +} from "../../B05_Profile/logic/B05_Profile_EndOffsets"; import { structureGroupMenuItems, stationIdAtStructure as stationIdAtStructureOf, @@ -608,6 +613,11 @@ export function createDesignProfile(ctx: DesignProfileContext) { if (key !== undefined) store.resetStation(Number(key), moveTo); }, isDirty: () => store.dirty(), + /** 시 · 종점 계획고 조정(448) — 첫 · 끝 측점 계획고 편집(m) · 기준이 없으면 null. */ + endOffsets: () => (base ? endOffsetsOf(base, store.edits()) : null), + /** 시 · 종점 계획고 조정 칸 → 그 측점 편집(끝점 ▲▼ 와 같은 길 · 가드 거침). false = 막힘. */ + setEndOffset: (end: ProfileEnd, value: number): boolean => + !!base && applyEdits(withEndOffset(base, store.edits(), end, value)), /** 계산 묶음(346-1) — 저장 노선의 종단 기준 · 지금 계획고 편집. */ pipelineSource: () => ({ base: listed(), edits: store.edits(), detail, routeId }), /** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림 — 초안 동안 편집은 노선 0 칸(저장 노선에 diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 7fa077b5e..68f65b9c5 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -2,7 +2,7 @@ * A00_Common/design/design_save.ts * B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례. * - * 노선 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 + * 노선 → 페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 * * 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan` * (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고 @@ -42,6 +42,7 @@ import { writeAlignmentDraft, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; +import { flushPageSettings } from "../../B05_Profile/B05_Profile_Api_PageSettings"; import { clearState, stateKey } from "../b_page_state"; import { carryAfterRouteSave, @@ -54,6 +55,7 @@ import { /** 저장 차례 — 이 순서 그대로 돈다(`route` 는 노선 초안이 있을 때만). */ export const DESIGN_SAVE_ORDER = [ "route", + "settings", "pipes", "structures", "plusStations", @@ -94,6 +96,7 @@ export function savingDesign(projectId: string | null): boolean { * 초안은 세션에 남아 다음 [저장]에서 다시 나감). */ const STEP_LABEL: Record = { route: "노선", + settings: "페이지 설정", pipes: "배수관", structures: "구조물", plusStations: "추가 측점", @@ -169,6 +172,8 @@ function defaultStep( confirm = false, ): () => Promise { switch (step) { + case "settings": // 노선 뒤 — 노선 저장이 새로 만든 종단에도 경고 기준이 얹힘(448) + return () => flushPageSettings(projectId); case "pipes": return () => flushPendingPipes(projectId, confirm); case "structures": diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index afdcc4f3f..778377ddf 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -416,6 +416,10 @@ def redesign_solve_request( **{key: options.get(key) for key in ("grade_class",) if options.get(key)}, paved=bool(options.get("paved", False)), terrain_type=str(options.get("terrain_type") or "special"), + # 설계속도 · 최소고 강제 · 시작 측점도 옮김 — 빠지면 노선 저장마다 20 · 꺼짐으로(448 E1) + design_speed_kph=options.get("design_speed_kph"), + enforce_pipe_clearance=params.get("enforce_pipe_clearance"), + station_display=params.get("station_display"), main_direction=str(options.get("main_direction") or "auto"), min_curve_radius_m=options.get("min_curve_radius_m"), max_uphill_grade=options.get("max_uphill_grade"), diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index 7f2f0a568..cabaf6440 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -147,6 +147,8 @@ export interface RouteLatestResponse { start_elevation_offset_m?: number | null; end_elevation_offset_m?: number | null; enforce_pipe_clearance?: boolean | null; + /** 시작 측점 · 누가거리 표시(448) — B05 라벨만. */ + station_display?: { station: number; cumulative: number } | null; } | null; } @@ -244,6 +246,28 @@ export async function saveProfileAlignment( ); } +/** B05 페이지 설정(448) — 사용자가 넣은 값 그대로(빈 칸 = null = 법정값). */ +export interface PageSettingsBody { + design_speed_kph: 20 | 30 | 40; + terrain_type: "normal" | "special"; + max_grade_pct: number | null; + min_vertical_radius_m: number | null; + min_tangent_length_m: number | null; + enforce_pipe_clearance: boolean; + station_display: { station: number; cumulative: number }; +} + +/** 페이지 설정을 stage 2 params 에 얹고 종단 경고 기준을 갈아 끼운다(기하 그대로). */ +export async function savePageSettings( + projectId: string, + body: PageSettingsBody, +): Promise<{ warnings: string[]; profile_updated: boolean }> { + return requestJson(`/projects/${projectId}/route/page-settings`, { + method: "PUT", + body: JSON.stringify(body), + }); +} + /** 확정 시 비정규 측점 횡단을 생성하기 위한 입력(빈 값이면 확정만 한다). */ export interface RouteConfirmRequest { filter_key?: string; diff --git a/B05_Profile/B05_Profile_Api_PageSettings.ts b/B05_Profile/B05_Profile_Api_PageSettings.ts new file mode 100644 index 000000000..561554ba7 --- /dev/null +++ b/B05_Profile/B05_Profile_Api_PageSettings.ts @@ -0,0 +1,67 @@ +/* ============================================================================= + * B05_Profile_Api_PageSettings.ts + * B05 「페이지 설정」 초안(448) — 세션 칸 `pagesettings` · [저장]의 `settings` 차례(노선 뒤 · 계획고 앞). + * + * 설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점을 조작 즉시 세션에 쌓고 [저장] · [확정]에서만 + * 서버로(자동저장 없음). 서버가 stage 2 params 에 얹고 종단 경고 기준을 갈아 끼운다(기하 그대로). + * 시작 측점은 B05 표시 전용 — 측점 열쇠 · 다른 페이지 라벨에 안 씀. + * 시 · 종점 계획고 조정은 여기 없음 — 첫 · 끝 측점 계획고 편집(`alignment` 차례)이 정본. + * ========================================================================== */ + +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { + readRouteLatestCache, + savePageSettings, + writeRouteLatestCache, + type PageSettingsBody, +} from "./B05_Profile_Api_Fetch"; +import type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields"; + +export type PageSettingsDraft = PageSettingsBody; + +/** 좌측 폼 값 → 초안(사용자가 넣은 값 그대로 · 빈 칸 = null). */ +export const pageSettingsOf = (values: RoutePanelValues): PageSettingsDraft => ({ + design_speed_kph: values.designSpeed, + terrain_type: values.terrainType, + max_grade_pct: values.maxGradePct, + min_vertical_radius_m: values.minVerticalRadius, + min_tangent_length_m: values.minTangentLength, + enforce_pipe_clearance: values.enforcePipeClearance, + station_display: values.stationDisplay, +}); + +/** 초안 → 좌측 폼 되살리기 값(최신 응답 위에 얹음). */ +export const panelValuesOfDraft = (draft: PageSettingsDraft): Partial => ({ + designSpeed: draft.design_speed_kph, + terrainType: draft.terrain_type, + maxGradePct: draft.max_grade_pct, + minVerticalRadius: draft.min_vertical_radius_m, + minTangentLength: draft.min_tangent_length_m, + enforcePipeClearance: draft.enforce_pipe_clearance, + stationDisplay: draft.station_display, +}); + +export const readPageSettings = (projectId: string): PageSettingsDraft | null => + readState("pagesettings", projectId); + +export const writePageSettings = (projectId: string, value: PageSettingsDraft): void => + writeState("pagesettings", value, projectId); + +/** [저장] `settings` 차례 — 초안이 없으면 안 보냄 · 성공하면 최신 응답 캐시에 얹고 초안을 비움 + * (실패 = 던짐 · 초안 그대로 다음 [저장]에서 다시). */ +export async function flushPageSettings(projectId: string): Promise { + const draft = readPageSettings(projectId); + if (!draft) return; + await savePageSettings(projectId, draft); + const latest = readRouteLatestCache(projectId); + if (latest?.route_params) { + const { design_speed_kph, terrain_type, ...top } = draft; + latest.route_params = { + ...latest.route_params, + ...top, + options: { ...latest.route_params.options, design_speed_kph, terrain_type }, + }; + writeRouteLatestCache(projectId, latest); + } + clearState("pagesettings", projectId); +} diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 863e075b7..5bd1d5a71 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -142,6 +142,12 @@ class AlignmentPolicy: max_grade_pct=float(payload.get("max_grade_pct") or 9.0), curve_skip_delta_pct=float(payload.get("curve_skip_delta_pct") or 5.0), paved=bool(payload.get("paved", False)), + # 평면 R 경고 기준도 물려받음 — 빠지면 계획고 저장마다 0(판정 안 함)으로(448) + min_plan_radius_m=float(payload.get("min_plan_radius_m") or 0.0), + hairpin_min_radius_m=float( + payload.get("hairpin_min_radius_m") + or FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"] + ), min_vertical_radius_m=float(payload.get("min_vertical_radius_m") or 0.0), min_curve_length_m=float(payload.get("min_curve_length_m") or 0.0), ) diff --git a/B05_Profile/B05_Profile_PageSettings.py b/B05_Profile/B05_Profile_PageSettings.py new file mode 100644 index 000000000..c58e610d6 --- /dev/null +++ b/B05_Profile/B05_Profile_PageSettings.py @@ -0,0 +1,125 @@ +"""B05 페이지 설정 저장(448) — stage 2 params 에 얹을 값 · 종단 기준 갈아 끼우기 · 옛 스냅샷 걷기. + +설계속도 · 지형 · 기준값은 **경고 기준만** 바꾼다 — 기하(base_pvi · 편집)는 그대로 · 종횡단 다시 +만들기 없음. params 에는 사용자가 넣은 원값만 둔다(해석값 저장 금지 — `resolve_grade_options`). +시작 측점은 B05 표시 전용 — 저장만 하고 측점 열쇠 · 다른 페이지 라벨에 쓰지 않는다. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Engine_Grade import ( + GradeDesignOptions, + legal_plan_radius_min_m, + resolve_grade_options, +) +from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile +from common_util.common_util_json import atomic_write_json + +#: params["options"] 안의 페이지 설정 칸 +PAGE_OPTION_KEYS = ("design_speed_kph", "terrain_type") +#: params 맨 위의 페이지 설정 칸(빈 값 = 법정값) +PAGE_TOP_KEYS = ( + "max_grade_pct", + "min_vertical_radius_m", + "min_tangent_length_m", + "enforce_pipe_clearance", + "station_display", +) +_STORED_KEYS = ("max_grade_pct", "min_vertical_radius_m", "min_tangent_length_m") + + +def params_patch(request: Any) -> dict[str, Any]: + """요청 → stage 2 params 에 얹을 값(원값 그대로).""" + return { + "options": {key: getattr(request, key) for key in PAGE_OPTION_KEYS}, + **{key: getattr(request, key) for key in PAGE_TOP_KEYS}, + } + + +def strip_page_settings(params: dict[str, Any] | None) -> dict[str, Any] | None: + """페이지 설정 칸만 걷어 기본값으로 — stage 2 params 를 안 뜬 옛 스냅샷의 [초기화].""" + if not isinstance(params, dict): + return params + stripped = {key: value for key, value in params.items() if key not in PAGE_TOP_KEYS} + options = params.get("options") + if isinstance(options, dict): + stripped["options"] = {k: v for k, v in options.items() if k not in PAGE_OPTION_KEYS} + return stripped + + +def grade_class_of(road_type: str | None, params: dict[str, Any]) -> str: + """기준 등급 — 노선 풀기가 남긴 값 · 없으면 B02 임도 종류(main → trunk · 그 밖 = work).""" + stored = (params.get("options") or {}).get("grade_class") + if stored: + return str(stored) + if road_type in ("main", "trunk"): + return "trunk" + return "fire" if road_type == "fire" else "work" + + +def grade_options_of(road_type: str | None, params: dict[str, Any]) -> GradeDesignOptions: + """params(사용자 원값) → 해석된 기준(작업임도 30 → 20 · 빈 칸 = 법정값).""" + options = params.get("options") or {} + speed = options.get("design_speed_kph") + return resolve_grade_options( + grade_class_of(road_type, params), + terrain_type=str(options.get("terrain_type") or "special"), + paved=bool(options.get("paved", False)), + main_direction=str(options.get("main_direction") or "auto"), + design_speed_kph=int(speed) if speed is not None else None, + stored={key: params[key] for key in _STORED_KEYS if params.get(key) is not None} + | {"enforce_pipe_clearance": params.get("enforce_pipe_clearance")}, + ) + + +def retarget_longitudinal( + longitudinal: dict[str, Any], options: GradeDesignOptions +) -> tuple[dict[str, Any], dict[str, Any]]: + """종단 정본의 정책 · 기준을 새 기준으로 — 저장 편집으로 다시 얹어 위반 · 요약만 새로. + + 정책 칸 = 최대 종단기울기 · 종단곡선 최소 R · 평면 최소 R(+ 있던 최소 L). 기하 칸은 그대로. + 반환 = (선형, 요약 `{id, ...}`) · `longitudinal` 은 제자리에서 바뀐다. + """ + stored = longitudinal.get("profile_alignment") + if not isinstance(stored, dict) or not stored.get("base_pvi"): + raise ValueError("저장된 계획선 선형이 없어 기준을 바꿀 수 없습니다.") + policy = dict(stored.get("policy") or {}) + policy["max_grade_pct"] = options.max_grade_pct + policy["min_vertical_radius_m"] = options.min_vertical_radius_m + policy["min_plan_radius_m"] = legal_plan_radius_min_m( + options.design_speed_kph, options.terrain_type + ) + if policy.get("min_curve_length_m"): + policy["min_curve_length_m"] = options.min_curve_length_m + stored["policy"] = policy + profiles = longitudinal.get("design_profiles") + previous = profiles[0] if isinstance(profiles, list) and profiles else {} + direction = (previous.get("criteria") or {}).get("resolved_main_direction", "none") + previous["criteria"] = {**options.as_dict(), "resolved_main_direction": direction} + longitudinal["design_profiles"] = [previous, *(profiles or [])[1:]] + alignment, profile = rebuild_alignment_profile(longitudinal, stored.get("edits")) + longitudinal["profile_alignment"] = alignment + longitudinal["design_profiles"][0] = profile + return alignment, {"id": profile["id"], **profile["summary"]} + + +def retarget_file( + project_root: Path, longitudinal_file_path: str, options: GradeDesignOptions +) -> dict[str, Any] | None: + """종단 정본 파일에 새 기준 — 요약을 돌려줌 · 계획선 선형이 없으면 None(파일 그대로).""" + root = Path(project_root).resolve() + path = (root / longitudinal_file_path).resolve() + if root not in path.parents: + raise ValueError("종단 데이터 경로가 프로젝트 밖을 가리킵니다.") + if not path.is_file(): + return None + longitudinal = json.loads(path.read_text(encoding="utf-8")) + if not (longitudinal.get("profile_alignment") or {}).get("base_pvi"): + return None + _, summary = retarget_longitudinal(longitudinal, options) + atomic_write_json(path, longitudinal) + return summary diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index 589a5c862..88a34f7b5 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -157,6 +157,7 @@ async def solve_route_record( "cross_sample_interval_m": request.cross_sample_interval_m, "long_sample_interval_m": request.long_sample_interval_m, **request.grade_options(), + "station_display": request.station_display, # B05 표시 전용(448) } log_b05_debug( logger, diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index d49503b57..79abc5471 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -11,22 +11,34 @@ from pathlib import Path from typing import Any from uuid import UUID +import aiomysql from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Debug import log_b05_debug -from B05_Profile.B05_Profile_Repository import confirm_route, get_latest_route +from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m +from B05_Profile.B05_Profile_PageSettings import grade_options_of, params_patch, retarget_file +from B05_Profile.B05_Profile_Repository import ( + confirm_route, + get_latest_route, + update_longitudinal_grade_summary, +) from B05_Profile.B05_Profile_Router_Confirm import ( _append_irregular_cross_sections, _merge_uphill_overrides_into_longitudinal, sync_uphill_overrides_into_designs, ) -from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteConfirmResponse +from B05_Profile.B05_Profile_Schema import ( + PageSettingsRequest, + PageSettingsResponse, + RouteConfirmRequest, + RouteConfirmResponse, +) from B06_Section.B06_Section_Repository import get_longitudinal_section from common_util.common_util_auth import get_optional_session from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_workflow_state import complete_stage, start_stage +from common_util.common_util_workflow_state import complete_stage, merge_stage_params, start_stage from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -149,6 +161,76 @@ async def confirm_latest_route( ) +@router.put("/{project_id}/route/page-settings", response_model=PageSettingsResponse) +async def save_page_settings( + project_id: UUID, request: PageSettingsRequest +) -> PageSettingsResponse | JSONResponse: + """B05 페이지 설정 [저장](448) — stage 2 params 에 사용자 값만 얹고(상태 · STALE 그대로) + 최신 종단 정본의 경고 기준을 갈아 끼워 위반 · 요약을 다시 셈. 기하 · 종횡단은 그대로.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT road_type FROM projects WHERE id = %s", (str(project_id),) + ) + row = await cursor.fetchone() + await connection.begin() + try: + async with connection.cursor(aiomysql.DictCursor) as cursor: + params = await merge_stage_params( + cursor, str(project_id), 2, params_patch(request) + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + options = grade_options_of(row[0] if row else None, params) + latest = await get_latest_route(connection, project_id) + longitudinal = ( + await get_longitudinal_section(connection, project_id, latest["id"]) + if latest + else None + ) + summary = None + if longitudinal: + stored_path = await get_project_storage_relative_path(connection, project_id) + summary = await asyncio.to_thread( + retarget_file, + Path(resolve_stored_project_path(stored_path)), + str(longitudinal["longitudinal_file_path"]), + options, + ) + if summary is not None: + await connection.begin() + try: + await update_longitudinal_grade_summary( + connection, route_id=latest["id"], grade_summary=summary + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + return PageSettingsResponse( + project_id=str(project_id), + design_speed_kph=options.design_speed_kph, + terrain_type=options.terrain_type, + max_grade_pct=options.max_grade_pct, + min_vertical_radius_m=options.min_vertical_radius_m, + min_plan_radius_m=legal_plan_radius_min_m( + options.design_speed_kph, options.terrain_type + ), + warnings=list(options.warnings), + profile_updated=summary is not None, + ) + except Exception: + logger.exception("B05 페이지 설정 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "페이지 설정 저장 중 오류가 발생했습니다."}, + ) + + @router.post("/{project_id}/route/reset") async def reset_route_design( project_id: UUID, diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 5383aee86..75a018f5f 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -187,6 +187,8 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]: (s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None ) params = (stage2 or {}).get("params") or {} + # 노선 풀기 · 페이지 설정 저장(448)은 `options` 안에 둠 — 옛 맨 위 칸은 폴백 + params = {**params, **{k: v for k, v in (params.get("options") or {}).items() if v}} speed = params.get("design_speed_kph") if isinstance(speed, (int, float)): design_speed = int(speed) diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index c943f5628..7ab3241d1 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -117,6 +117,8 @@ class RouteSolveRequest(BaseModel): enforce_pipe_clearance: bool | None = Field( default=None, description="횡단배수 최소 계획고 강제 (기본 해제)" ) + # 시작 측점 · 누가거리 표시(448) — B05 라벨만 · 다시 풀어도 params 에 그대로 옮김. + station_display: dict[str, float] | None = None @model_validator(mode="after") def validate_choices(self) -> "RouteSolveRequest": @@ -217,6 +219,50 @@ class ProfileAlignmentSaveRequest(BaseModel): return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} +class PageSettingsRequest(BaseModel): + """B05 페이지 설정 [저장](448) — 사용자가 넣은 값 그대로(빈 칸 = null = 법정값).""" + + model_config = ConfigDict(extra="forbid") + + design_speed_kph: int = 20 + terrain_type: str = "special" + max_grade_pct: float | None = Field(default=None, gt=0) + min_vertical_radius_m: float | None = Field(default=None, gt=0) + min_tangent_length_m: float | None = Field(default=None, gt=0) + enforce_pipe_clearance: bool = False + #: 시작 측점 · 누가거리 — B05 표시 전용(측점 열쇠 · 다른 페이지 라벨에 안 씀) + station_display: dict[str, float] | None = None + + @model_validator(mode="after") + def validate_choices(self) -> "PageSettingsRequest": + if self.design_speed_kph not in (20, 30, 40): + raise ValueError("설계속도는 20/30/40 중 하나여야 합니다.") + if self.terrain_type not in GRADE_TERRAIN_TYPES: + raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.") + if self.station_display is not None: + station = self.station_display.get("station", 0) + self.station_display = { + "station": float(max(0, round(station))), + "cumulative": float(self.station_display.get("cumulative", 0)), + } + return self + + +class PageSettingsResponse(BaseModel): + """페이지 설정 저장 결과 — 해석된 기준(작업임도 30 → 20 등) · 경고.""" + + status: str = "success" + project_id: str + design_speed_kph: int + terrain_type: str + max_grade_pct: float + min_vertical_radius_m: float + min_plan_radius_m: float + warnings: list[str] = Field(default_factory=list) + #: 종단 정본의 기준을 갈아 끼우고 위반 · 요약을 다시 셈했나(종단이 없으면 false) + profile_updated: bool = False + + class ProfileAlignmentSaveResponse(BaseModel): """계획선 편집 저장 결과 (재계산된 정본 선형을 그대로 돌려준다).""" diff --git a/B05_Profile/B05_Profile_UI_Page_Latest.ts b/B05_Profile/B05_Profile_UI_Page_Latest.ts index 186b828c0..6a5b0ebec 100644 --- a/B05_Profile/B05_Profile_UI_Page_Latest.ts +++ b/B05_Profile/B05_Profile_UI_Page_Latest.ts @@ -10,6 +10,7 @@ import { writeRouteLatestCache, type RouteLatestResponse, } from "./B05_Profile_Api_Fetch"; +import { panelValuesOfDraft, readPageSettings } from "./B05_Profile_Api_PageSettings"; import type { RoutePanelValues } from "./B05_Profile_UI_Panel"; /** @@ -63,9 +64,11 @@ export function createLatestSource(projectId: string) { return { read, write, load, verify }; } -/** 저장된 노선 설정값 → 좌측 폼 되살리기 값. */ +/** 저장된 노선 설정값 → 좌측 폼 되살리기 값 — 세션 페이지 설정 초안이 있으면 그 위에(448). + * 시 · 종점 계획고 조정은 종단 편집이 비추므로 여기서 안 채움(옛 params 값은 자동 선형에 이미 듦). */ export function panelValuesOf(next: RouteLatestResponse): Partial { const options = next.route_params?.options ?? {}; + const draft = readPageSettings(next.project_id); return { contourInterval: next.surface_params.contour_interval_m, gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined, @@ -77,9 +80,9 @@ export function panelValuesOf(next: RouteLatestResponse): Partial { + callbacks.onStationDisplayChange(offset); + rememberSettings(); + }, + // 시 · 종점 계획고 조정 = 종단도 끝 측점 편집(448) — 종단도는 한 벌(`shared.profile`) + endOffsets: () => shared.profile?.endOffsets() ?? null, + onEndOffsetChange: (end, value) => void shared.profile?.setEndOffset(end, value), + }); const recommend = createRecommendPart(); // 노선 추천 칸 — 동작은 노선 편집기가 건다(345-4) const { contourInterval, @@ -118,6 +128,37 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh enforcePipeClearance, ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); + // 페이지 설정 초안(448) — 고친 즉시 세션에 · [저장]이 내보냄(시 · 종점 조정은 계획고 편집이라 뺌) + [ + designSpeed, + terrainType, + maxGradePct, + minVerticalRadius, + minTangentLength, + enforcePipeClearance, + ].forEach((input) => input.addEventListener("change", rememberSettings)); + function rememberSettings(): void { + writePageSettings(shared.projectId, pageSettingsOf(values())); + } + + function values(): RoutePanelValues { + return { + contourInterval: Number(contourInterval.value) || 1, + gradeClass: gradeClass.value as RoutePanelValues["gradeClass"], + stationInterval: STATION_INTERVAL_M, + crossSampleInterval: parseOptional(crossSampleInterval), + longSampleInterval: parseOptional(longSampleInterval), + designSpeed: settings.currentSpeed(), + terrainType: terrainType.value as RoutePanelValues["terrainType"], + maxGradePct: parseOptional(maxGradePct), + minVerticalRadius: parseOptional(minVerticalRadius), + minTangentLength: parseOptional(minTangentLength), + startElevationOffset: parseOptional(startElevationOffset), + endElevationOffset: parseOptional(endElevationOffset), + enforcePipeClearance: enforcePipeClearance.checked, + stationDisplay: settings.stationDisplayOffset(), + }; + } // 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 · // [확정]은 한 벌이 두 보기 몫을 모아 낸다. @@ -136,23 +177,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh gradeLimitPct: settings.gradeLimitPct, /** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */ setCorridorPending, - values(): RoutePanelValues { - return { - contourInterval: Number(contourInterval.value) || 1, - gradeClass: gradeClass.value as RoutePanelValues["gradeClass"], - stationInterval: STATION_INTERVAL_M, - crossSampleInterval: parseOptional(crossSampleInterval), - longSampleInterval: parseOptional(longSampleInterval), - designSpeed: settings.currentSpeed(), - terrainType: terrainType.value as RoutePanelValues["terrainType"], - maxGradePct: parseOptional(maxGradePct), - minVerticalRadius: parseOptional(minVerticalRadius), - minTangentLength: parseOptional(minTangentLength), - startElevationOffset: parseOptional(startElevationOffset), - endElevationOffset: parseOptional(endElevationOffset), - enforcePipeClearance: enforcePipeClearance.checked, - }; - }, + values, restore(values: Partial) { if (values.contourInterval != null) contourInterval.value = String(values.contourInterval); if (values.gradeClass) gradeClass.value = values.gradeClass; @@ -167,17 +192,24 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh } if (values.designSpeed) designSpeed.value = String(values.designSpeed); if (values.terrainType) terrainType.value = values.terrainType; - if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct); - if (values.minVerticalRadius != null) - minVerticalRadius.value = String(values.minVerticalRadius); - if (values.minTangentLength != null) minTangentLength.value = String(values.minTangentLength); + // 기준값 — null = 사용자가 비운 칸(초안 · 448) · undefined = 그대로 + const typed: Array<[HTMLInputElement, number | null | undefined]> = [ + [maxGradePct, values.maxGradePct], + [minVerticalRadius, values.minVerticalRadius], + [minTangentLength, values.minTangentLength], + ]; + typed.forEach(([input, value]) => { + if (value !== undefined) input.value = value === null ? "" : String(value); + }); if (values.startElevationOffset != null) startElevationOffset.value = String(values.startElevationOffset); if (values.endElevationOffset != null) endElevationOffset.value = String(values.endElevationOffset); if (values.enforcePipeClearance != null) enforcePipeClearance.checked = values.enforcePipeClearance; + if (values.stationDisplay) settings.setStationDisplay(values.stationDisplay); settings.syncCriteria(); + settings.showEndOffsets(); }, }; } diff --git a/B05_Profile/B05_Profile_UI_Panel_Fields.ts b/B05_Profile/B05_Profile_UI_Panel_Fields.ts index 9b1fc2097..550298ce1 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Fields.ts @@ -28,6 +28,8 @@ export interface RoutePanelValues { /** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시). * 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */ enforcePipeClearance: boolean; + /** 시작 측점 · 누가거리 표시(448 저장) — B05 라벨만 · 측점 열쇠 · 다른 페이지에 안 씀. */ + stationDisplay: { station: number; cumulative: number }; } /** diff --git a/B05_Profile/B05_Profile_UI_Panel_Settings.ts b/B05_Profile/B05_Profile_UI_Panel_Settings.ts index eb61e2853..017784cfb 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Settings.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Settings.ts @@ -25,6 +25,10 @@ export interface SettingsPartCallbacks { onContourApply: (interval: number) => void | Promise; /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; + /** 시 · 종점 계획고 조정(448) = 첫 · 끝 측점 계획고 편집 — 지금 값(기준 없으면 null). */ + endOffsets?: () => { start: number; end: number } | null; + /** 칸을 바꿈 → 그 끝 측점 편집으로(빈 칸 = 0). */ + onEndOffsetChange?: (end: "start" | "end", value: number) => void; } export function createSettingsPart(callbacks: SettingsPartCallbacks) { @@ -73,6 +77,11 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) { const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample")); const longSampleInterval = numberField(L("B05_Route_Field_LongSample")); + // 읽기 전용(448) — 바꾸면 종횡단을 다시 표본해야 해서 여기서 고치지 않음. + [crossSampleInterval, longSampleInterval].forEach((input) => { + input.disabled = true; + input.title = "종횡단 표본 간격 — 바꾸려면 종횡단을 다시 만들어야 합니다(읽기 전용)."; + }); // 샘플 간격 두 칸은 2열 한 행 — 종단이 먼저(2026-08-18 사용자 지시). const sampleRow = document.createElement("div"); sampleRow.className = "b05-route__field-row"; @@ -102,11 +111,38 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) { const minTangentLength = numberField("최소 직선 길이 (m)"); const startElevationOffset = numberField("시점 계획고 조정 (m)"); const endElevationOffset = numberField("종점 계획고 조정 (m)"); + // 시 · 종점 계획고 조정 = 첫 · 끝 측점 계획고 편집(448) — 칸은 종단 편집을 비추고 바꾸면 그 편집으로. + const endFields = { start: startElevationOffset, end: endElevationOffset }; + function showEndOffsets(): void { + const offsets = callbacks.endOffsets?.(); + (["start", "end"] as const).forEach((end) => { + endFields[end].disabled = !offsets; + if (offsets && document.activeElement !== endFields[end]) + endFields[end].value = offsets[end] ? String(Number(offsets[end].toFixed(3))) : ""; + }); + } + (["start", "end"] as const).forEach((end) => { + endFields[end].placeholder = "0"; + endFields[end].title = + "자동 계획선 대비 이 끝 측점의 계획고 편집(m) — 종단도 끝점 ▲▼ 와 같은 값."; + endFields[end].addEventListener("change", () => { + callbacks.onEndOffsetChange?.(end, parseOptional(endFields[end]) ?? 0); + showEndOffsets(); // 막힌 편집(최소고 가드)이면 옛 값으로 돌아옴 + }); + }); const criteriaNote = document.createElement("p"); criteriaNote.className = "b05-route__note"; const gradeAdvanced = document.createElement("details"); const gradeSummary = document.createElement("summary"); gradeSummary.textContent = "기준값 직접 지정"; + // 칸을 펼 때 · 펴 둔 동안 누름 · 키 뒤마다 종단 편집을 다시 비춤(끝점 ▲▼ · 되돌리기 · [저장] 뒤) + // ponytail: 문서 듣기는 페이지마다 하나 · 떼지 않음(가벼움) — 새는 게 보이면 페이지 수명에 묶을 것 + gradeAdvanced.addEventListener("toggle", () => gradeAdvanced.open && showEndOffsets()); + const refreshSoon = (): void => { + if (gradeAdvanced.isConnected && gradeAdvanced.open) requestAnimationFrame(showEndOffsets); + }; + document.addEventListener("pointerup", refreshSoon); + document.addEventListener("keyup", refreshSoon); gradeAdvanced.append( gradeSummary, maxGradePct.wrapper, @@ -224,6 +260,12 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) { enforcePipeClearance, }, stationDisplayOffset, + /** 저장된 시작 측점 · 누가거리를 칸에 되살림(알림 없음). */ + setStationDisplay(next: { station: number; cumulative: number }) { + startStation.value = String(next.station); + startCumulative.value = String(next.cumulative); + }, + showEndOffsets, gradeLimitPct, currentSpeed, syncSpeedChoices, diff --git a/B05_Profile/logic/B05_Profile_EndOffsets.ts b/B05_Profile/logic/B05_Profile_EndOffsets.ts new file mode 100644 index 000000000..d32d8b47f --- /dev/null +++ b/B05_Profile/logic/B05_Profile_EndOffsets.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * logic/B05_Profile_EndOffsets.ts + * 「페이지 설정」 시 · 종점 계획고 조정(448) = 첫 · 끝 측점의 계획고 편집(`station_offsets`) 한 칸. + * 칸 값 = 자동 선형 대비 그 측점 편집(m) · 바꾸면 끝점 ▲▼ 와 같은 길(`adjustStation`)로 얹음 — + * 계산 줄 즉시 · [저장] `alignment` 차례가 서버 정본 · [초기화]는 종단 파일과 함께 0. + * ========================================================================== */ + +import { + adjustStation, + chainageKey, + type AlignmentBase, + type AlignmentEdits, +} from "./B05_Profile_UI_Profile_Alignment"; + +export type ProfileEnd = "start" | "end"; + +function endChainage(base: AlignmentBase, end: ProfileEnd): number | undefined { + const nodes = base.basePvi; + return (end === "start" ? nodes[0] : nodes[nodes.length - 1])?.chainage_m; +} + +/** 첫 · 끝 측점 계획고 편집(m) — 편집이 없으면 0. */ +export function endOffsetsOf( + base: AlignmentBase, + edits: AlignmentEdits, +): Record { + const at = (end: ProfileEnd): number => { + const chainage = endChainage(base, end); + return chainage === undefined ? 0 : (edits.station_offsets[chainageKey(chainage)] ?? 0); + }; + return { start: at("start"), end: at("end") }; +} + +/** 그 끝 측점 계획고 편집을 `value`(m)로 — 기준이 없으면 편집 그대로. */ +export function withEndOffset( + base: AlignmentBase, + edits: AlignmentEdits, + end: ProfileEnd, + value: number, +): AlignmentEdits { + const chainage = endChainage(base, end); + if (chainage === undefined || !Number.isFinite(value)) return edits; + return adjustStation(base, edits, chainage, value - endOffsetsOf(base, edits)[end]); +} diff --git a/B06_Section/B06_Section_Router_Regenerate_Options.py b/B06_Section/B06_Section_Router_Regenerate_Options.py index f0627caba..2ddc30ca9 100644 --- a/B06_Section/B06_Section_Router_Regenerate_Options.py +++ b/B06_Section/B06_Section_Router_Regenerate_Options.py @@ -39,11 +39,11 @@ def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDes ) if stage_params.get(key) is not None } - # 설계속도는 B05가 저장해 둔 값을 그대로 따른다 — 없으면 종류별 기본(20). + # 설계속도 · 지형은 B05가 저장해 둔 값 — 없으면 종류별 기본(20) · B05 기본 특수(448 E2). speed = options.get("design_speed_kph") return resolve_grade_options( str(grade_class), - terrain_type=str(options.get("terrain_type") or "normal"), + terrain_type=str(options.get("terrain_type") or "special"), paved=bool(options.get("paved", False)), design_speed_kph=int(speed) if speed is not None else None, stored=stored, diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index d970396df..fcaa00f55 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -218,6 +218,8 @@ async def save_initial_snapshot( dump: dict[str, Any] = {"routes": _json_safe([dict(route)])} for table in _CHILD_TABLES: dump[table] = _json_safe(await _dump_rows(connection, table, route_id)) + # stage 2 params(제어점 · 페이지 설정)도 — [초기화]가 함께 되돌림(448 E3) + dump["stage2_params"] = await _stage2_params(connection, str(route["project_id"])) (target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8") @@ -322,9 +324,40 @@ async def restore_initial_snapshot( if "project_id" in child: child["project_id"] = project_id await _insert_row(connection, table, child) + await _restore_stage2_params(connection, project_id, dump) return new_id +async def _stage2_params(connection: aiomysql.Connection, project_id: str) -> Any: + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT params FROM project_workflow_stages WHERE project_id = %s AND stage_no = 2", + (project_id,), + ) + row = await cursor.fetchone() + raw = row[0] if row else None + return json.loads(raw) if isinstance(raw, str) else raw + + +async def _restore_stage2_params( + connection: aiomysql.Connection, project_id: str, dump: dict[str, Any] +) -> None: + """뜬 stage 2 params 로 되씀 — 안 뜬 옛 스냅샷은 페이지 설정 칸만 걷어 기본값(448 E3).""" + from B05_Profile.B05_Profile_PageSettings import strip_page_settings + + if "stage2_params" in dump: + params = dump["stage2_params"] + else: + params = strip_page_settings(await _stage2_params(connection, project_id)) + if params is None: + return + async with connection.cursor() as cursor: + await cursor.execute( + "UPDATE project_workflow_stages SET params = %s WHERE project_id = %s AND stage_no = 2", + (json.dumps(params, ensure_ascii=False), project_id), + ) + + async def _insert_row(connection: aiomysql.Connection, table: str, row: dict[str, Any]) -> int: columns = list(row.keys()) placeholders = ", ".join(["%s"] * len(columns)) diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index aaac39d96..c5c85c27b 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -88,6 +88,37 @@ async def start_stage( ) +def merged_params(params: Dict[str, Any] | None, patch: Dict[str, Any]) -> Dict[str, Any]: + """params 에 patch 를 얹는다 — `options` 는 칸끼리 합침 · 나머지는 갈음(448).""" + merged = dict(params or {}) + for key, value in patch.items(): + if key == "options" and isinstance(value, dict): + merged["options"] = {**(merged.get("options") or {}), **value} + else: + merged[key] = value + return merged + + +async def merge_stage_params( + cursor: aiomysql.DictCursor, project_id: str, stage_no: int, patch: Dict[str, Any] +) -> Dict[str, Any]: + """단계 params 에 patch 만 얹는다 — 상태 · 이후 단계(STALE)는 그대로(448 페이지 설정).""" + await cursor.execute( + "SELECT params FROM project_workflow_stages " + "WHERE project_id = %s AND stage_no = %s FOR UPDATE", + (project_id, stage_no), + ) + row = await cursor.fetchone() + raw = (row["params"] if isinstance(row, dict) else row[0]) if row else None + params = json.loads(raw) if isinstance(raw, str) else raw + merged = merged_params(params if isinstance(params, dict) else None, patch) + await cursor.execute( + "UPDATE project_workflow_stages SET params = %s WHERE project_id = %s AND stage_no = %s", + (json.dumps(merged, ensure_ascii=False), project_id, stage_no), + ) + return merged + + async def complete_stage(cursor: aiomysql.DictCursor, project_id: str, stage_no: int) -> None: """단계를 완료 상태로 전환한다.""" now = datetime.utcnow() diff --git a/resources/tester/helper_442_2_page_settings.cjs b/resources/tester/helper_442_2_page_settings.cjs new file mode 100644 index 000000000..18e064ea8 --- /dev/null +++ b/resources/tester/helper_442_2_page_settings.cjs @@ -0,0 +1,175 @@ +/* PLAN 448(442-2) 단위검증 헬퍼 — 페이지 설정 저장 차례 · 초안 내보내기 · 기준표 거울 재료. + * ① DESIGN_SAVE_ORDER 에 settings(route 뒤 · pipes 앞) · 노선 바꾼 [저장]에도 settings 기본 창구가 돎 + * ② flushPageSettings — 초안 없음 = 안 보냄 · 성공 = 최신 응답 캐시에 얹고 초안 비움 · 실패 = 초안 그대로 + * ③ 끝 측점 계획고 편집(시 · 종점 조정) — 칸 값 = 첫 · 끝 측점 station_offsets + * ④ TS 기준표(PROFILE_CRITERIA · SPEED_CHOICES) — 거울 시험이 파이썬과 대조 + * TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구는 가짜. 짝 = `test_442_2_page_settings.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +globalThis.window = { setTimeout, clearTimeout }; + +const anything = () => + new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) }); + +function load(file, stubs) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r] ?? anything()); + return box.exports; +} + +/** 상대 경로 import 를 실제 파일로 따라 읽는 로더(순수 logic 묶음용) · 그 밖은 가짜. */ +const cache = new Map(); +function loadReal(file) { + if (cache.has(file)) return cache.get(file); + const box = { exports: {} }; + cache.set(file, box.exports); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const req = (r) => { + if (!r.startsWith(".")) return anything(); + const target = path.resolve(path.dirname(file), r); + return loadReal(fs.existsSync(`${target}.ts`) ? `${target}.ts` : target); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(file, box.exports); + return box.exports; +} + +async function run() { + const out = {}; + + // ── ① 저장 차례 ── + const calls = []; + const save = load(path.join(ROOT, "A00_Common", "design", "design_save.ts"), { + "../../B05_Profile/B05_Profile_Api_PageSettings": { + flushPageSettings: async (projectId) => calls.push(`settings:${projectId}`), + }, + "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": { + pendingRouteSave: async () => ({ draft: 1 }), + saveRouteDraft: async () => (calls.push("route"), { route_id: 9 }), + carryAfterRouteSave: async () => [], + routeSaveSummary: () => "", + rebuildAfterRouteSave: async () => undefined, + }, + }); + out.order = [...save.DESIGN_SAVE_ORDER]; + const done = await save.saveDesign({ + projectId: "p1", + routeId: () => 3, + steps: { settings: async () => calls.push("own-settings") }, // 노선 바뀌면 화면 몫 건너뜀 + cross: async () => calls.push("cross"), + failed: (error) => calls.push(`failed:${error.message}`), + }); + out.routeSaveDone = done; + out.routeSaveCalls = calls; + + // ── ② 초안 내보내기 ── + const session = new Map(); + const puts = []; + let mode = "ok"; + let latest = { + route_params: { options: { grade_class: "trunk", design_speed_kph: 20 }, max_grade_pct: 15 }, + }; + const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettings.ts"), { + "../A00_Common/b_page_state": { + readState: (name, p) => (session.has(`${name}:${p}`) ? session.get(`${name}:${p}`) : null), + writeState: (name, value, p) => session.set(`${name}:${p}`, value), + clearState: (name, p) => session.delete(`${name}:${p}`), + }, + "./B05_Profile_Api_Fetch": { + savePageSettings: async (projectId, body) => { + puts.push(body); + if (mode === "boom") throw new Error("서버 500"); + return { warnings: [], profile_updated: true }; + }, + readRouteLatestCache: () => latest, + writeRouteLatestCache: (_p, value) => (latest = value), + }, + }); + await api.flushPageSettings("p1"); + out.putsWithoutDraft = puts.length; + const draft = api.pageSettingsOf({ + designSpeed: 30, + terrainType: "normal", + maxGradePct: null, + minVerticalRadius: 300, + minTangentLength: null, + enforcePipeClearance: true, + stationDisplay: { station: 12, cumulative: 240 }, + }); + out.draft = draft; + api.writePageSettings("p1", draft); + out.restoreFromDraft = api.panelValuesOfDraft(api.readPageSettings("p1")); + mode = "boom"; + out.boom = await api.flushPageSettings("p1").then( + () => "ok", + (error) => error.message, + ); + out.draftAfterBoom = api.readPageSettings("p1"); + mode = "ok"; + await api.flushPageSettings("p1"); + out.draftAfterOk = api.readPageSettings("p1"); + out.latestAfterOk = latest; + out.puts = puts.length; + + // ── ③ 끝 측점 계획고 편집 ── + const logic = path.join(ROOT, "B05_Profile", "logic"); + const alignment = loadReal(path.join(logic, "B05_Profile_UI_Profile_Alignment.ts")); + const ends = loadReal(path.join(logic, "B05_Profile_EndOffsets.ts")); + const base = { + policy: { + station_interval_m: 20, + curve_radius_ratio: 5, + default_curve_length_m: 20, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: true, + balance_tolerance_percent: 5, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 5, + paved: false, + }, + onlyExplicitCurves: false, + basePvi: [ + { chainage_m: 0, elevation_m: 100 }, + { chainage_m: 100, elevation_m: 105 }, + { chainage_m: 200, elevation_m: 108 }, + ], + chainage: [0, 100, 200], + ground: [100, 105, 108], + stations: [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200].map((s) => ({ + station_id: `s${s}`, + chainage_m: s, + })), + }; + const empty = { station_offsets: {}, curve_radii: {} }; + out.endsEmpty = ends.endOffsetsOf(base, empty); + const raised = ends.withEndOffset(base, empty, "start", 1.25); + out.endsRaised = ends.endOffsetsOf(base, raised); + const both = ends.withEndOffset(base, raised, "end", -0.5); + out.endsBoth = ends.endOffsetsOf(base, both); + const plan = alignment.buildAlignment(base, both); + out.planStart = plan.samples[0].elevation_m; + out.planEnd = plan.samples[plan.samples.length - 1].elevation_m; + out.backToZero = ends.endOffsetsOf(base, ends.withEndOffset(base, both, "start", 0)); + + // ── ④ 기준표 ── + const fields = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Fields.ts"), {}); + out.criteria = fields.PROFILE_CRITERIA; + out.speedChoices = fields.SPEED_CHOICES; + + process.stdout.write(JSON.stringify(out)); +} + +run().catch((error) => { + process.stderr.write(String(error.stack || error)); + process.exit(1); +}); diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 31520794f..b2e350a0b 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -74,6 +74,8 @@ const STUBS = { "../../B06_Section/B06_Section_Api_Culvert_Options": { flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`), }, + // 448 페이지 설정 — 초안 없음(차례만 · 내보내기는 test_442_2_page_settings) + "../../B05_Profile/B05_Profile_Api_PageSettings": { flushPageSettings: async () => undefined }, }; const cache = new Map(); diff --git a/resources/tester/test_442_2_grade_limit_mirror.py b/resources/tester/test_442_2_grade_limit_mirror.py new file mode 100644 index 000000000..9dce7a895 --- /dev/null +++ b/resources/tester/test_442_2_grade_limit_mirror.py @@ -0,0 +1,80 @@ +"""PLAN 448(442-2) 거울 — 「페이지 설정」 TS 기준표 ↔ 파이썬 법정 기준 전수. + +TS `B05_Profile_UI_Panel_Fields.ts` PROFILE_CRITERIA · SPEED_CHOICES 와 `gradeLimitPct` 규칙 +(고를 수 없는 속도 = 첫 선택지 · 넣은 값 > 0 이면 그 값 · 아니면 표)을 파이썬 +`FOREST_ROAD_PROFILE_CRITERIA` · `resolve_grade_options(...).max_grade_pct` 와 임도 종류 × 속도 × +지형 × 입력 유무 전부 대조. TS 표는 `helper_442_2_page_settings.cjs` 가 꺼냄. + +./venv/Scripts/python.exe -m pytest resources/tester/test_442_2_grade_limit_mirror.py -q +""" + +from __future__ import annotations + +import itertools +import json +import subprocess +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options +from config.config_system_design import FOREST_ROAD_PROFILE_CRITERIA as PY + +HERE = Path(__file__).parent + + +@pytest.fixture(scope="module") +def ts_tables(): + 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 + out = json.loads(proc.stdout) + return out["criteria"], out["speedChoices"] + + +def test_속도별_기울기_반경_표가_같음(ts_tables): + criteria, _ = ts_tables + assert sorted(int(speed) for speed in criteria) == sorted(PY["design_speed"]) + for speed, legal in PY["design_speed"].items(): + ts = criteria[str(speed)] + assert ts["grade"] == legal["max_grade_pct"] + assert ts["radius"] == legal["min_vertical_radius_m"] + + +def test_종류별_속도_선택지가_같음(ts_tables): + _, choices = ts_tables + for road, allowed in choices.items(): + assert tuple(allowed) == tuple(PY["selectable_design_speeds"].get(road, (20,))) + + +def _ts_limit(criteria, choices, road, speed, terrain, typed): + allowed = choices.get(road, [20]) + speed = speed if speed in allowed else allowed[0] # syncSpeedChoices + if typed is not None and typed > 0: + return float(typed) + return float(criteria[str(speed)]["grade"][terrain]) + + +def test_기울기_상한_전수(ts_tables): + criteria, choices = ts_tables + cases = itertools.product( + ["trunk", "fire", "work", "branch"], [20, 30, 40], ["normal", "special"], [None, 11.5] + ) + for road, speed, terrain, typed in cases: + py = resolve_grade_options( + road, + terrain_type=terrain, + design_speed_kph=speed, + stored={} if typed is None else {"max_grade_pct": typed}, + ) + assert _ts_limit(criteria, choices, road, speed, terrain, typed) == py.max_grade_pct, ( + road, + speed, + terrain, + typed, + ) diff --git a/resources/tester/test_442_2_page_settings.py b/resources/tester/test_442_2_page_settings.py new file mode 100644 index 000000000..494919197 --- /dev/null +++ b/resources/tester/test_442_2_page_settings.py @@ -0,0 +1,404 @@ +"""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 # 옛 정책 = 판정 안 함 그대로 diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py index 900248884..25e0daace 100644 --- a/resources/tester/test_design_store_save.py +++ b/resources/tester/test_design_store_save.py @@ -50,6 +50,7 @@ def test_계획고_가게_한_벌(out): def test_저장_차례(out): assert out["order"] == [ "route", + "settings", # 448 페이지 설정 — 노선 뒤 "pipes", "structures", "plusStations", # 368-2 추가 측점 — 계획고 앞