From 4609ff0d7ff7b7dfe98e77d7cc83304c059a2501 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 28 Oct 2026 21:09:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(B06):=20=EB=8B=A8=EB=A9=B4=20=EC=9C=A0?= =?UTF-8?q?=ED=98=95=20=EC=97=86=EB=8A=94=20=EC=98=9B=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=3D=20=EC=83=81=EB=8B=A8?= =?UTF-8?q?=EC=B8=A1=20=EA=B9=8E=EA=B8=B0=20=ED=95=98=EB=82=98=20(PLAN=204?= =?UTF-8?q?72-8=20=E2=91=A0=20=C2=B7=20=EC=9A=94=EA=B5=AC=20409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 브라우저 다시 셈(Cross_Refresh_Calc) · 묶음 cross 단계(Cross_Stage) 의 좌 깎기 고정 폴백 걷음 · cross 단계는 이미 있는 uphillSide(Pipeline_Detail) 다시 씀 - 서버 [저장] 두 길도 같은 규칙 — 파이썬 stored_section_mode(Router_Design 셋 · SampleExtend) · Node Cross_Save storedOptions · Node 에 측점 uphill_side 넘김(Server_Calc_Prebuild) · 화면만 상단측이면 [저장] 뒤 좌 깎기로 뒤집혀 같이 고침 - 값 바뀜 = section_mode 없는 설계뿐(지금 DB 16 측점 · 한 프로젝트) · 그 밖 0 차 - 거울 시험 test_472_8_section_mode_default(파이썬 = Node = 브라우저 · 없음 = 상단측 적음) Co-Authored-By: Claude Opus 5.5 --- .../B06_Section_Engine_SampleExtend.py | 3 +- B06_Section/B06_Section_Router_Design.py | 16 ++- .../B06_Section_Server_Calc_Prebuild.py | 2 +- .../logic/B06_Section_Cross_Refresh_Calc.ts | 5 +- B06_Section/logic/B06_Section_Cross_Save.ts | 6 +- B06_Section/logic/B06_Section_Cross_Stage.ts | 5 +- .../tester/helper_472_8_section_mode.cjs | 60 +++++++++ .../tester/test_472_8_section_mode_default.py | 118 ++++++++++++++++++ 8 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 resources/tester/helper_472_8_section_mode.cjs create mode 100644 resources/tester/test_472_8_section_mode_default.py diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index 66a02fe02..d6a98cbf8 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -127,6 +127,7 @@ def extend_unclosed_sections( ford_surface_drops, stored_berm, stored_cut_slope, + stored_section_mode, ) from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M @@ -146,7 +147,7 @@ def extend_unclosed_sections( elevation = design_elevation_from_longitudinal(longitudinal, chainage) options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), - section_mode=str(design.get("section_mode") or "left_cut"), + section_mode=stored_section_mode(design, section), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=bool(design.get("paved", False)), diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 19096659e..c2bfbd4ef 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -179,7 +179,7 @@ def enforce_pavement_ranges( try: options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), - section_mode=str(design.get("section_mode") or "left_cut"), + section_mode=stored_section_mode(design, section), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=wanted, @@ -230,7 +230,7 @@ def enforce_design_lines( try: options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), - section_mode=str(design.get("section_mode") or "left_cut"), + section_mode=stored_section_mode(design, section), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=bool(design.get("paved", False)), @@ -280,7 +280,7 @@ def enforce_ford_surface_drops( try: options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), - section_mode=str(design.get("section_mode") or "left_cut"), + section_mode=stored_section_mode(design, section), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=bool(design.get("paved", False)), @@ -395,6 +395,16 @@ def attach_default_designs( section["design"] = design +def stored_section_mode(stored: dict[str, Any], section: dict[str, Any]) -> str: + """저장분 단면 유형 — 없으면(옛 설계) 상단측 깎기(지형이 정함 · 요구 409) · 상단측 모르면 좌. + + 짝: TS `B06_Section_Cross_Save` storedOptions · `B06_Section_Cross_Refresh_Calc` · + `B06_Section_Cross_Stage`(`uphillSide`) · 거울 시험 `test_472_8_section_mode_default.py`. + """ + side = section.get("uphill_side") + return str(stored.get("section_mode") or f"{side if side in ('left', 'right') else 'left'}_cut") + + def stored_cut_slope(stored: dict[str, Any]) -> float | None: """저장분에 남은 측점별 암 절토 경사비 — 없거나 0(표준값으로 되돌림)이면 None. diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 843b6392c..bf32e7e97 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -310,7 +310,7 @@ def _enforce_on_node( from common_util.common_util_json import atomic_write_json from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M - keys = ("chainage_m", "design", "samples", "frame", *CURVE_KEYS) + keys = ("chainage_m", "design", "samples", "frame", "uphill_side", *CURVE_KEYS) profiles = longitudinal.get("design_profiles") or [] results = run_calc( ["cross.enforce"], diff --git a/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts b/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts index 4a01067a2..bf1b76a26 100644 --- a/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts +++ b/B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts @@ -209,9 +209,12 @@ export function refreshDesigns( groundType: choice?.ground_type ?? (typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"), + // 없으면(옛 설계) 상단측 깎기 · 모르면 좌(요구 409 · 서버 `stored_section_mode` 짝) sectionMode: choice?.section_mode ?? - (typeof design.section_mode === "string" ? design.section_mode : "left_cut"), + (typeof design.section_mode === "string" && design.section_mode + ? design.section_mode + : `${section.uphill_side === "right" ? "right" : "left"}_cut`), ditchSide: choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null), // 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다. diff --git a/B06_Section/logic/B06_Section_Cross_Save.ts b/B06_Section/logic/B06_Section_Cross_Save.ts index b9cf7a1d8..57e6a1610 100644 --- a/B06_Section/logic/B06_Section_Cross_Save.ts +++ b/B06_Section/logic/B06_Section_Cross_Save.ts @@ -165,7 +165,10 @@ export function elevationAt( function storedOptions(design: Raw, section: Raw, rockDefaultM: number): Raw { return { ground_type: String(design.ground_type || "ripping_rock"), - section_mode: String(design.section_mode || "left_cut"), + // 없으면(옛 설계) 상단측 깎기 · 모르면 좌(요구 409 · 파이썬 `stored_section_mode`) + section_mode: String( + design.section_mode || `${section.uphill_side === "right" ? "right" : "left"}_cut`, + ), ditch_side: design.ditch_side ?? null, ditch_type: String(design.ditch_type || "standard"), paved: Boolean(design.paved), @@ -190,6 +193,7 @@ export interface SaveSection { plan_radius_m?: number | null; curve_outer_side?: string | null; curve_widening_m?: number | null; + uphill_side?: "left" | "right" | null; } export interface SaveCrossInput { diff --git a/B06_Section/logic/B06_Section_Cross_Stage.ts b/B06_Section/logic/B06_Section_Cross_Stage.ts index 11d79590d..dbe6436d5 100644 --- a/B06_Section/logic/B06_Section_Cross_Stage.ts +++ b/B06_Section/logic/B06_Section_Cross_Stage.ts @@ -29,10 +29,11 @@ import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Gro import type { DtmGrid } from "@util/common_util_surface_sampler"; import type { CulvertPipePoint } from "@util/common_util_culvert_sets"; import { facilityAt, facilityRanges, type FacilityStructure } from "./B06_Section_Cross_Enforce"; +import { uphillSide } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; export { groundSamples }; -/** 측점 하나의 횡단 입력 — 비면 기본(지반 리핑암 · 좌절토 · 표준 측구 · 표준 경사). 확폭은 측점(stations)이 줌. */ +/** 측점 하나의 횡단 입력 — 비면 기본(지반 리핑암 · 상단측 절토 · 표준 측구 · 표준 경사). 확폭은 측점(stations)이 줌. */ export type CrossStationInput = Partial< Omit >; @@ -53,7 +54,7 @@ function optionsFor( ): CrossDesignOptions { return { groundType: "ripping_rock", - sectionMode: "left_cut", + sectionMode: `${uphillSide(station) ?? "left"}_cut`, // 상단측 깎기 · 모르면 좌(요구 409) ditchType: "standard", ...input, standard, diff --git a/resources/tester/helper_472_8_section_mode.cjs b/resources/tester/helper_472_8_section_mode.cjs new file mode 100644 index 000000000..fecefc6a9 --- /dev/null +++ b/resources/tester/helper_472_8_section_mode.cjs @@ -0,0 +1,60 @@ +/* PLAN 472-8 헬퍼 — 브라우저 다시 셈(`B06_Section_Cross_Refresh_Calc.refreshDesigns`)을 Node 로. + * 입력 argv[2] JSON {sections, plan: [[누가거리, 계획고]], standard} · 결과 = 표준출력 JSON(측점 설계 목록). + * 짝 = `test_472_8_section_mode_default.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.endsWith(".json")) + return require( + request.startsWith("@util/") + ? path.join(ROOT, "common_util", request.slice(6)) + : path.join(path.dirname(full), request), + ); + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const { refreshDesigns } = loadTs( + path.join(ROOT, "B06_Section", "logic", "B06_Section_Cross_Refresh_Calc.ts"), +); +const samples = job.plan.map(([chainage_m, elevation_m]) => ({ chainage_m, elevation_m })); +const alignment = { + stations: samples.map((s) => ({ chainage_m: s.chainage_m, plan_elevation_m: s.elevation_m })), + samples, + balance: { cut_area_m2: 0, fill_area_m2: 0, cut_volume_m3: 0, fill_volume_m3: 0 }, +}; +const detail = { longitudinal: { design_profiles: [] }, cross_sections: job.sections }; +refreshDesigns( + detail, + { + alignment, + standard: job.standard, + rockOffsets: [], + rockDefault: job.rockDefault, + cutSlopes: [], + bermSpans: null, + bermDepths: [], + choices: [], + shoulderExtends: [], + canon: [], + }, + null, +); +process.stdout.write(JSON.stringify(detail.cross_sections.map((s) => s.design))); diff --git a/resources/tester/test_472_8_section_mode_default.py b/resources/tester/test_472_8_section_mode_default.py new file mode 100644 index 000000000..ec65730a9 --- /dev/null +++ b/resources/tester/test_472_8_section_mode_default.py @@ -0,0 +1,118 @@ +"""PLAN 472-8 (요구 409) — 단면 유형이 없는 (옛) 설계의 기본 = 상단측 깎기 하나 +(지형이 정함 · 모르면 좌). + +못박는 것 + - 서버 [저장] 파이썬 길(`stored_section_mode`) + = Node 길(TS `B06_Section_Cross_Save` storedOptions). + - 브라우저 다시 셈(TS `B06_Section_Cross_Refresh_Calc`) = 같은 규칙. + - 셋 다 「단면 유형 없음」 = 「상단측 깎기를 적어 넣음」과 0 차 + · 우 상단측 측점은 옛 좌 깎기와 다름. + - 묶음 cross 단계(`B06_Section_Cross_Stage`) 기본도 상단측 + (`uphillSide` 다시 씀 · 좌 고정 폴백 없음). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_8_section_mode_default.py -q +""" + +from __future__ import annotations + +import copy +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for path in (ROOT, HERE): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from test_511_b4_cross_save_mirror import SURFACE, _facilities, _npz, _plan, _sections # noqa: E402 + +from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild # noqa: E402 +from B06_Section.B06_Section_Engine_Design_Node import full_standard # noqa: E402 +from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _uphill(index: int) -> str | None: + return None if index == 3 else ("right" if index % 2 else "left") + + +def _expected(index: int) -> str: + return f"{_uphill(index) or 'left'}_cut" + + +def _stripped(sections: list[dict], mode=None) -> list[dict]: + """단면 유형을 지운 옛 설계 · `mode(index)` 가 오면 그 값을 적어 넣은 짝.""" + out = copy.deepcopy(sections) + for index, section in enumerate(out): + section["uphill_side"] = _uphill(index) + section["design"].pop("section_mode", None) + if mode: + section["design"]["section_mode"] = mode(index) + return out + + +def _server(root: Path, longitudinal: dict, sections: list[dict]) -> tuple[list, list]: + py, node = copy.deepcopy(sections), copy.deepcopy(sections) + prebuild._enforce_on_python(longitudinal, py, root, None, SURFACE) + grid = prebuild.design_grid_file(root, SURFACE) + assert prebuild._enforce_on_node(longitudinal, node, root, None, grid) + return [s["design"] for s in py], [s["design"] for s in node] + + +def _browser(tmp_path: Path, sections: list[dict]) -> list[dict]: + job = tmp_path / "job.json" + plan = [[s["chainage_m"], _plan(s["chainage_m"])] for s in sections] + job.write_text( + json.dumps( + { + "sections": sections, + "plan": plan, + "standard": full_standard(None), + "rockDefault": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( + ["node", str(HERE / "helper_472_8_section_mode.cjs"), str(job)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_단면_유형_없음_상단측_서버_두_길_브라우저_같음(tmp_path, monkeypatch): + root = tmp_path / "p" + _npz(root) + _facilities(monkeypatch) + longitudinal, sections = _sections(root) + bare = _stripped(sections) + + py, node = _server(root, longitudinal, bare) + assert py == node # 파이썬 길 = Node 길(TS Cross_Save) + py_set, _ = _server(root, longitudinal, _stripped(sections, _expected)) + assert py == py_set # 없음 = 상단측 적어 넣음 + py_left, _ = _server(root, longitudinal, _stripped(sections, lambda _i: "left_cut")) + assert any(a != b for a, b in zip(py, py_left)) # 옛 좌 고정과 다름(우 상단측 측점) + + browser = _browser(tmp_path, bare) + assert browser == _browser(tmp_path, _stripped(sections, _expected)) + assert [d["section_mode"] for d in browser] == [d["section_mode"] for d in py] + assert browser != _browser(tmp_path, _stripped(sections, lambda _i: "left_cut")) + + +def test_묶음_cross_단계_기본은_상단측(): + stage = (ROOT / "B06_Section/logic/B06_Section_Cross_Stage.ts").read_text(encoding="utf-8") + assert 'sectionMode: `${uphillSide(station) ?? "left"}_cut`' in stage + assert '"left_cut"' not in stage From 2da632dc4544b859be36d2e3f02ed5460944eba9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 28 Oct 2026 21:09:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(B05=C2=B7B06):=20=EB=B6=80?= =?UTF-8?q?=EB=A6=84=200=20=EC=98=9B=20=EC=A4=84=20=C2=B7=20=EB=8A=98=20?= =?UTF-8?q?=EA=B1=B4=EB=84=88=EB=9B=B0=EB=8A=94=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EC=A7=80=EC=9B=80=20(PLAN=20472-8=20=E2=91=A1=20=E2=91=A2=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EA=B5=AC=20409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지움 - flushDesignStore(design_store · 부름 0) — 시험 344-4 의 flush 대조 한 줄도 - b_page_state 옛 세션 열쇠 옮김(legacy · migrate) · b_project_switch 옛 키 머리 — 옛 열쇠는 세션 저장소(탭 닫으면 사라짐) · 지금 코드가 안 씀 · storage · DB 에 없음 · 시험 290-1 · 401 · 384-1 의 옛 키 줄 뺌 - 시험만 부르던 TS — planFootprint(346-2 거울은 footprintRecord + slopePieces 로 그대로) · shiftSegment · shiftMovingPoints(쉬프트 단추는 09-02 지움 · min_cover_guard 쉬프트 시험 뺌) · dropStationsNear(test_b05_station_merge.mjs 지움) · createIrregularStationsSection(부름 0) · generalStations(234-1 은 기본 맞춤 값으로 같은 것 잼) · sameSummaryItem - test_b05_b06_save_roundtrip + helper_b05_save_payload 둘 — 프로젝트 936be972 storage 없어 늘 skip · 같은 저장 왕복은 test_472_6_design_save(창구 원자성) · test_449_save_pipes(관 PUT) · test_238_1_save_keeps_structure_stations(구조물 측점)가 잼 남김 - migrateLegacyStations — 부르는 곳 있음(design_view_profile_sections) · storage 에 비정규 측점 남음 - common_util_excavation 셋 — 지금은 운영이 부름(design_calc_node) Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_page_state.ts | 81 +-- A00_Common/b_project_switch.ts | 8 +- A00_Common/design/design_store.ts | 18 +- .../B05_Profile_UI_IrregularStations.ts | 486 +--------------- .../logic/B05_Profile_PlanFootprint.ts | 14 - .../B05_Profile_UI_Profile_Alignment_Edit.ts | 74 +-- B05_Profile/logic/B05_Profile_Util_Station.ts | 21 - common_util/common_util_structure_station.ts | 2 - common_util/common_util_summary_item.ts | 6 - .../tester/helper_234_1_structure_station.cjs | 1 - .../tester/helper_290_1_project_purge.cjs | 3 - .../tester/helper_344_4_design_pipeline.cjs | 3 - .../tester/helper_401_project_isolation.cjs | 7 - resources/tester/helper_b05_save_payload.mjs | 111 ---- .../tester/helper_b05_save_payload_entry.ts | 9 - .../helper_b05_structures_form_summary.cjs | 6 - .../tester/test_234_1_structure_station.py | 3 +- resources/tester/test_290_1_project_purge.py | 4 +- .../tester/test_344_4_design_pipeline.py | 1 - .../test_346_2_plan_footprint_mirror.py | 4 +- resources/tester/test_384_1_session_keys.py | 2 +- .../tester/test_401_project_isolation.py | 4 +- .../tester/test_b05_b06_save_roundtrip.py | 518 ------------------ resources/tester/test_b05_min_cover_guard.py | 53 -- resources/tester/test_b05_station_merge.mjs | 37 -- .../test_b05_structures_form_summary.py | 1 - resources/tester/test_b06_patch_station.py | 2 +- 27 files changed, 42 insertions(+), 1437 deletions(-) delete mode 100644 resources/tester/helper_b05_save_payload.mjs delete mode 100644 resources/tester/helper_b05_save_payload_entry.ts delete mode 100644 resources/tester/test_b05_b06_save_roundtrip.py delete mode 100644 resources/tester/test_b05_station_merge.mjs diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 71c7ea6f0..1535f8fc4 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -32,8 +32,6 @@ export interface StateEntry { scope: StateScope; /** 형식이 바뀌면 올린다 — 옛 값을 읽지 않고 기본값으로 시작한다. */ version?: number; - /** 옛 키(있으면 한 번 읽어 옮기고 지운다). `project`·`route` 범위는 함수로 받는다. */ - legacy?: (projectId?: string, routeId?: number | string) => string; } /** @@ -48,14 +46,13 @@ export const STATE_REGISTRY = { "profile-collapsed": { bucket: "pref", scope: "global", - legacy: () => "b05-route-profile-collapsed", }, - "profile-height": { bucket: "pref", scope: "global", legacy: () => "b05-route-profile-height" }, + "profile-height": { bucket: "pref", scope: "global" }, /** B06 위 평면 겹침 패널 높이(436) */ "plan-peek-height": { bucket: "pref", scope: "global" }, /** 배수유역 판 접힘 — 기억 없음 = 닫힘(392-1 · 옛 "false" 기억 버림). */ "drainage-collapsed": { bucket: "pref", scope: "global", version: 2 }, - "drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" }, + "drainage-width": { bucket: "pref", scope: "global" }, /** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */ "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" }, @@ -77,26 +74,22 @@ export const STATE_REGISTRY = { "masshaul-open": { bucket: "pref", scope: "global", - legacy: () => "b05-route-profile-masshaul-open", }, "masshaul-height": { bucket: "pref", scope: "global", - legacy: () => "b05-route-profile-masshaul-height", }, /** 유토곡선 범례 — B05·B06 이 같은 값을 본다(예전에도 키를 공유했다). */ "masshaul-visible": { bucket: "pref", scope: "global", version: 6, - legacy: () => "b06:masshaul-visible-v4", }, - "table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" }, - "table-height": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:height" }, + "table-open": { bucket: "pref", scope: "global" }, + "table-height": { bucket: "pref", scope: "global" }, "section-panel-collapsed": { bucket: "pref", scope: "global", - legacy: () => "b06:profile-panel-collapsed", }, /** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */ "cross-columns": { bucket: "pref", scope: "global" }, @@ -109,7 +102,6 @@ export const STATE_REGISTRY = { "section-panel-height": { bucket: "pref", scope: "global", - legacy: () => "b06:profile-panel-height", }, /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ @@ -120,21 +112,20 @@ export const STATE_REGISTRY = { /** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */ "detail-table": { bucket: "draft", scope: "route" }, /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ - structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` }, + structures: { bucket: "draft", scope: "project" }, /** 아직 정본에 안 넣은 추가 측점(정수 m 목록 · 368-2) — 정본 `plus_stations.json` · [저장]이 내보냄. */ "plus-stations": { bucket: "draft", scope: "project" }, /** 3D 램프로 바꾼 측점 상단측(측구 방향). */ - uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` }, + uphill: { bucket: "draft", scope: "project" }, /** B05 배수유역도에서 고친 관 목록(추가·이동·삭제) — 2026-09-06 되살림. * 예전에는 패널 메모리에만 있어 B06 으로 넘어가면 편집이 사라졌다(대응표 조사). * 정본은 `pipe_points.json` 이고, 이 초안은 [저장]·[확정] 한 본문(`pipesPart`)이 * 내보낸 뒤 비운다. */ - pipes: { bucket: "draft", scope: "project", legacy: (p) => `b05:pipes:${p}` }, + pipes: { bucket: "draft", scope: "project" }, /** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */ "structure-pick": { bucket: "draft", scope: "project", - legacy: (p) => `aislo:structure-pick:${p}`, }, /** 관 세션 id 장부(323-5 · `design_pipe_ids`) — `{next, ids: [{id, at}]}` · 저장 안 함. */ "pipe-ids": { bucket: "draft", scope: "project" }, @@ -147,9 +138,9 @@ export const STATE_REGISTRY = { /** 종류를 바꾼 관 자리 `{누가거리: 처음 종류}`(318-4) — [저장] 때 그 측점 옛 조정값을 정본에서도 비움. */ kindreset: { bucket: "draft", scope: "project" }, /** 배수관·암거 조정창에서 예약한 옵션 값. */ - culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` }, + culvertopt: { bucket: "draft", scope: "route" }, /** 암 경계선 오프셋(측점별). */ - rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, + rockb: { bucket: "draft", scope: "route" }, /** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07). * 암 경계선(`rockb`)과 같은 자리·같은 꼴이다. 재계산에 **계산 전에** 실어 보내야 * 설계선이 새 경사로 그려진다(값만 베껴 붙이면 그림과 숫자가 어긋난다). */ @@ -164,24 +155,23 @@ export const STATE_REGISTRY = { * (`B06_Section_Api_Fetch` seedStandardCross) — 같은 응답의 `standard_cross_section` 은 * config 기본값. 고침으로 안 본다 — [폐기]에도 안 지움(`design_leave` NOT_EDITS). * [초기화] · 추천 노선 [적용]에서 버린다. */ - "std-cross": { bucket: "draft", scope: "project", legacy: (p) => `b06:std-cross:${p}` }, + "std-cross": { bucket: "draft", scope: "project" }, /** 표시 반폭 — 사용자가 고른 값이라 초안이되, 바꾸면 횡단 재생성(③)을 함께 부른다. */ "cross-display": { bucket: "draft", scope: "route", - legacy: (p, r) => `b06:cross-display:${p}:${r}`, }, /* 측점 조정창이 쌓는 4축·단수·연동 값 — 전부 노선 단위 초안이고 [저장]에서 함께 나간다. 옛 키는 `b06:{이름}:{프로젝트}:{노선}` 한 규칙이었다. */ - crossw: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:crossw:${p}:${r}` }, - revetx: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetx:${p}:${r}` }, - revetlink: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetlink:${p}:${r}` }, - basinadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:basinadjust:${p}:${r}` }, - inletstruct: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:inletstruct:${p}:${r}` }, - extrawall: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extrawall:${p}:${r}` }, - fordadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:fordadjust:${p}:${r}` }, - boxadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:boxadjust:${p}:${r}` }, - extraspan: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extraspan:${p}:${r}` }, + crossw: { bucket: "draft", scope: "route" }, + revetx: { bucket: "draft", scope: "route" }, + revetlink: { bucket: "draft", scope: "route" }, + basinadjust: { bucket: "draft", scope: "route" }, + inletstruct: { bucket: "draft", scope: "route" }, + extrawall: { bucket: "draft", scope: "route" }, + fordadjust: { bucket: "draft", scope: "route" }, + boxadjust: { bucket: "draft", scope: "route" }, + extraspan: { bucket: "draft", scope: "route" }, /** 횡단 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈) 선택 — 2026-09-06. * 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고 * [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */ @@ -215,17 +205,17 @@ export const STATE_REGISTRY = { /** D04 연도별 지정 업체 목록(531-3) — [저장] 때 PUT plan/assignees. */ "d04-assignees": { bucket: "draft", scope: "project" }, /* D02 · E 페이지 캐시(401) — 진입 · 떠남 비우기가 같은 표를 봄 · 옛 키는 한 번 옮김. */ - "d02-points": { bucket: "draft", scope: "project", legacy: (p) => `d02_points_${p}` }, - "e02-ground": { bucket: "draft", scope: "project", legacy: (p) => `e02_ground_cache:${p}` }, - "e03-prob": { bucket: "draft", scope: "project", legacy: (p) => `e03_prob_cache:${p}` }, - "e04-settings": { bucket: "draft", scope: "project", legacy: (p) => `e04_settings:${p}` }, - "e05-report": { bucket: "draft", scope: "project", legacy: (p) => `e05_report_cache:${p}` }, - "e06-rock": { bucket: "draft", scope: "project", legacy: (p) => `e06_rock_cache:${p}` }, - "e07-settings": { bucket: "draft", scope: "project", legacy: (p) => `e07_settings:${p}` }, + "d02-points": { bucket: "draft", scope: "project" }, + "e02-ground": { bucket: "draft", scope: "project" }, + "e03-prob": { bucket: "draft", scope: "project" }, + "e04-settings": { bucket: "draft", scope: "project" }, + "e05-report": { bucket: "draft", scope: "project" }, + "e06-rock": { bucket: "draft", scope: "project" }, + "e07-settings": { bucket: "draft", scope: "project" }, /* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */ /** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */ - latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` }, + latest: { bucket: "result", scope: "project" }, /** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을 * 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리). * 노선이 바뀌면 `latest` 와 함께 버린다. */ @@ -236,13 +226,11 @@ export const STATE_REGISTRY = { "std-cross-default": { bucket: "result", scope: "project", - legacy: (p) => `b06:std-cross-default:${p}`, }, /** 서버가 준 암 경계선 기본 오프셋 — 위와 같은 성격. */ "rock-boundary-default": { bucket: "result", scope: "project", - legacy: (p) => `b06:rock-boundary-default:${p}`, }, /** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값). * 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */ @@ -487,20 +475,6 @@ function setOrMakeRoom(store: Storage, key: string, value: string): void { } } -/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */ -function migrate( - name: StateName, - key: string, - projectId?: string | null, - routeId?: number | string | null, -): void { - const legacy = entryOf(name)?.legacy?.(projectId ?? undefined, routeId ?? undefined); - if (!legacy || legacy === key) return; - const old = readRaw(legacy); - if (old !== null && readRaw(key) === null) writeRaw(key, old); - if (old !== null) writeRaw(legacy, null); -} - /** 문자열 그대로 읽는다(숫자·참거짓처럼 JSON 이 아닌 값). */ export function readStateRaw( name: StateName, @@ -509,7 +483,6 @@ export function readStateRaw( ): string | null { const key = stateKey(name, projectId, routeId); if (!key) return null; - migrate(name, key, projectId, routeId); return readRaw(key); } diff --git a/A00_Common/b_project_switch.ts b/A00_Common/b_project_switch.ts index 5e300651d..2e1b9292d 100644 --- a/A00_Common/b_project_switch.ts +++ b/A00_Common/b_project_switch.ts @@ -65,10 +65,7 @@ export function reloadIfOtherProject(): boolean { return false; } -const MARK_P = "\u0001"; -const MARK_R = "\u0002"; - -/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */ +/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`). 초안 여부를 함께. */ function projectPrefixes(): Array<{ prefix: string; draft: boolean }> { const out: Array<{ prefix: string; draft: boolean }> = []; Object.entries(STATE_REGISTRY as Record).forEach(([name, entry]) => { @@ -76,9 +73,6 @@ function projectPrefixes(): Array<{ prefix: string; draft: boolean }> { const draft = entry.bucket === "draft"; const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft }); - const legacy = entry.legacy?.(MARK_P, MARK_R); - const at = legacy?.indexOf(MARK_P) ?? -1; - if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft }); }); return out; } diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts index ac058ec3b..33ae5bff3 100644 --- a/A00_Common/design/design_store.ts +++ b/A00_Common/design/design_store.ts @@ -173,14 +173,10 @@ type MemoryKey = keyof typeof MEMORY_SLOTS; /** 멈춘 뒤 세션에 한 번 쓰기까지(ms). */ export const PERSIST_DELAY_MS = 1000; const memory = new Map(); -const persistTimers = new Map< - string, - { timer: ReturnType; write: () => void } ->(); +const persistTimers = new Map>(); function cancelPersist(storageKey: string): void { - const pending = persistTimers.get(storageKey); - if (pending) clearTimeout(pending.timer); + clearTimeout(persistTimers.get(storageKey)); persistTimers.delete(storageKey); } @@ -189,14 +185,6 @@ onStateRemoved((storageKey) => { cancelPersist(storageKey); }); -/** 미뤄 둔 세션 쓰기를 지금 — [저장] 앞 · 시험. */ -export function flushDesignStore(): void { - const writes = [...persistTimers.values()]; - persistTimers.forEach((pending) => clearTimeout(pending.timer)); - persistTimers.clear(); - writes.forEach((pending) => pending.write()); -} - function memorySlot(key: MemoryKey, projectId: string | null): Slot { const name = MEMORY_SLOTS[key]; const storageKey = name ? stateKey(name, projectId) : projectId && `memory:${key}:${projectId}`; @@ -223,7 +211,7 @@ function memorySlot(key: MemoryKey, projectId: string | null): Slot { persistTimers.delete(storageKey); writeState(name, memory.get(storageKey) ?? null, projectId); }; - persistTimers.set(storageKey, { timer: setTimeout(write, PERSIST_DELAY_MS), write }); + persistTimers.set(storageKey, setTimeout(write, PERSIST_DELAY_MS)); }, }; } diff --git a/B05_Profile/B05_Profile_UI_IrregularStations.ts b/B05_Profile/B05_Profile_UI_IrregularStations.ts index 11c57897d..0c5132ab7 100644 --- a/B05_Profile/B05_Profile_UI_IrregularStations.ts +++ b/B05_Profile/B05_Profile_UI_IrregularStations.ts @@ -1,32 +1,20 @@ /* ============================================================================= * B05_Profile_UI_IrregularStations.ts - * 비정규 측점(구조물 측점) 입력·목록·편집 사이드바 섹션. + * 비정규 측점(구조물 측점) 꼴 · 배수관 판정 · 표시 라벨 — 종단 그래프 · 표 · 3D 가 같이 씀. * - * 사용자가 규칙 격자(측점 간격) 밖의 임의 위치(측점번호 X + 잔여거리 XX)에 구조물 측점을 - * 추가한다. 지금은 구조물을 자유 텍스트로 받고, 값은 클라이언트에만 보관한다(백엔드 미영속). - * 추가/수정/삭제/리셋이 일어날 때마다 `onChange`로 전체 목록을 알려 Page가 그래프·테이블·3D에 - * 반영한다. 목록에서 항목을 고르면 `onSelect`로 chainage를 알려 하이라이트에 쓴다. - * - * chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다. + * chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). * ========================================================================== */ import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog"; import { ESCAPE_ROUTE_DEFAULT_WIDTH_M, - ESCAPE_ROUTE_WIDTHS_M, PIPE_DEFAULT_DIAMETER_MM, PIPE_DEFAULT_TYPE, - PIPE_DIAMETERS_MM, - PIPE_TYPES, STRUCTURE_ETC_DEFAULT_NAME, - STRUCTURE_TYPES, - pickPipeDiameter, type StructureType, } from "@config/config_frontend"; -import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; // 그래프 측점 id · 측점 표기 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음 -import { irregularLabel } from "./logic/B05_Profile_Profile_Planted"; export { irregularLabel, irregularStationId } from "./logic/B05_Profile_Profile_Planted"; export interface IrregularStation { @@ -98,473 +86,3 @@ export function structureLabel(entry: { return entry.structure ?? ""; } } - -export interface IrregularStationsSection { - root: HTMLElement; - getStations: () => IrregularStation[]; - /** chainage로 목록 항목을 골라 폼에 로드한다(그래프·3D에서 선택 시). null이면 선택 해제. */ - selectByChainage: (chainageM: number | null) => void; - /** 외부(백엔드 복귀)에서 목록을 통째로 채운다(측점번호·잔여거리는 chainage로 역산). */ - setStations: (seed: Array<{ chainage_m: number; structure: string }>) => void; - /** 배수유역도의 관 목록을 "배관" 구조물로 갈아 끼운다. 사용자가 손으로 넣은 항목은 건드리지 않는다. - * effective_diameter_mm(배수 유효직경)를 주면 그 이상 첫 규격을 자동 지정한다(기본 D800). */ - setPipeStations: ( - pipes: ReadonlyArray, - ) => void; - /** 우클릭 메뉴에서 배관 외 구조물을 기본 옵션으로 추가한다(종단·배수유역도 공용). */ - addStructure: (chainageM: number, structureType: "기성막이" | "대피로" | "기타") => void; - /** 누가거리로 항목을 찾아 지운다(종단 테이블 우클릭). 지웠으면 그 항목을 돌려준다. */ - removeByChainage: (chainageM: number) => IrregularStation | null; - /** 누가거리로 항목을 옮긴다(종단 테이블 라인 드래그). 옮겼으면 true. */ - moveByChainage: (fromChainageM: number, toChainageM: number) => boolean; - clear: () => void; -} - -interface IrregularStationsCallbacks { - /** 측점간격(m). chainage 환산에 쓴다. */ - getInterval: () => number; - /** 목록이 바뀔 때(추가·수정·삭제·리셋) 전체 목록을 넘긴다. */ - onChange: (stations: IrregularStation[]) => void; - /** 목록에서 항목을 선택/해제할 때 해당 측점(또는 null)을 넘긴다. */ - onSelect: (station: IrregularStation | null) => void; -} - -function field(labelText: string, input: HTMLElement): HTMLLabelElement { - const wrapper = document.createElement("label"); - wrapper.className = "b05-route__field"; - const caption = document.createElement("span"); - caption.textContent = labelText; - wrapper.append(caption, input); - return wrapper; -} - -function numberInput(step: string, min: string): HTMLInputElement { - const input = document.createElement("input"); - input.className = "ui-input"; - input.type = "number"; - input.step = step; - input.min = min; - return input; -} - -function selectInput(options: ReadonlyArray): HTMLSelectElement { - const select = document.createElement("select"); - select.className = "ui-select"; - select.replaceChildren(...options.map((value) => new Option(value, value))); - return select; -} - -export function createIrregularStationsSection( - callbacks: IrregularStationsCallbacks, -): IrregularStationsSection { - const root = document.createElement("section"); - // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시). - root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section"; - const heading = document.createElement("h3"); - heading.className = "ui-collapsible__title"; - heading.textContent = "구조물 배치"; - const body = document.createElement("div"); - body.className = "b05-route__panel-body"; - root.append(heading, body); - - const stationField = numberInput("1", "0"); - stationField.placeholder = "측점번호"; - const remainderField = numberInput("0.1", "0"); - remainderField.placeholder = "잔여거리"; - - // 구조물 종류 + 종류별 하위 옵션 (2026-08-05 사용자 확정. 목록은 config_frontend가 정본). - // 첫 항목은 빈칸 — 리셋 시 "선택 안 됨" 상태를 표현한다(빈칸이면 하위 옵션 전체 숨김). - const typeSelect = selectInput(["", ...STRUCTURE_TYPES]); - const pipeTypeSelect = selectInput(PIPE_TYPES); - const diameterSelect = document.createElement("select"); - diameterSelect.className = "ui-select"; - const widthSelect = selectInput(ESCAPE_ROUTE_WIDTHS_M.map((width) => width.toFixed(1))); - const nameField = document.createElement("input"); - nameField.className = "ui-input"; - nameField.type = "text"; - nameField.placeholder = STRUCTURE_ETC_DEFAULT_NAME; - - const pipeTypeField = field("관종", pipeTypeSelect); - const diameterField = field("직경", diameterSelect); - const widthField = field("폭 (m)", widthSelect); - const nameFieldWrap = field("이름", nameField); - const subRow = document.createElement("div"); - subRow.className = "b05-route__irregular-row"; - subRow.append(pipeTypeField, diameterField, widthField, nameFieldWrap); - - /** 관종에 맞는 직경 목록으로 갈아 끼운다. 가능하면 현재 선택을 유지한다. */ - function syncDiameterOptions(keep?: number): void { - const sizes = PIPE_DIAMETERS_MM[pipeTypeSelect.value] ?? PIPE_DIAMETERS_MM[PIPE_DEFAULT_TYPE]; - const wanted = keep ?? (Number(diameterSelect.value) || PIPE_DEFAULT_DIAMETER_MM); - diameterSelect.replaceChildren( - ...sizes.map((size) => new Option(`D${size}`, String(size), false, size === wanted)), - ); - if (!sizes.includes(wanted)) - diameterSelect.value = String(pickPipeDiameter(pipeTypeSelect.value, wanted)); - } - - /** 종류에 따라 보이는 하위 옵션을 바꾼다 — 배관: 관종+직경 / 대피로: 폭 / 기타: 이름. - * 빈칸(선택 안 됨)이면 하위 옵션 전체를 숨긴다(리셋 직후 상태). */ - function syncSubOptions(): void { - const type = typeSelect.value as StructureType | ""; - pipeTypeField.hidden = diameterField.hidden = type !== "배관"; - widthField.hidden = type !== "대피로"; - nameFieldWrap.hidden = type !== "기타"; - // 기성막이는 하위 옵션 미정(추가반영 예정), 빈칸은 아무것도 안 보인다. - subRow.hidden = type === "기성막이" || type === ""; - } - typeSelect.addEventListener("change", () => { - syncSubOptions(); - // 편집 중 구조물 타입을 바꾸면 기존 항목 수정이 아니라 **신규 추가**로 전환한다 - // (2026-08-05 사용자 지시). 입력값은 그대로 두고 편집 상태만 푼다. - if (editingId) { - editingId = null; - syncButtons(); - renderList(); - callbacks.onSelect(null); - } - }); - pipeTypeSelect.addEventListener("change", () => syncDiameterOptions()); - typeSelect.value = ""; // 초기·리셋 상태는 빈칸(2026-08-05 사용자 지시) - pipeTypeSelect.value = PIPE_DEFAULT_TYPE; - widthSelect.value = ESCAPE_ROUTE_DEFAULT_WIDTH_M.toFixed(1); - syncDiameterOptions(PIPE_DEFAULT_DIAMETER_MM); - syncSubOptions(); - - const stationRow = document.createElement("div"); - stationRow.className = "b05-route__irregular-row"; - stationRow.append(field("측점번호", stationField), field("잔여거리 (m)", remainderField)); - - const primary = document.createElement("button"); - primary.type = "button"; - primary.className = "ui-btn ui-btn--filled b05-route__irregular-btn is-primary"; - const remove = document.createElement("button"); - remove.type = "button"; - remove.className = "ui-btn ui-btn--danger-ghost b05-route__irregular-btn is-danger"; - remove.textContent = "삭제"; - const reset = document.createElement("button"); - reset.type = "button"; - reset.className = "ui-btn ui-btn--ghost b05-route__irregular-btn"; - reset.textContent = "리셋"; - const actions = document.createElement("div"); - actions.className = "b05-route__irregular-actions"; - actions.append(primary, remove, reset); - - const list = document.createElement("ul"); - list.className = "b05-route__irregular-list"; - const help = document.createElement("p"); - help.className = "ui-text ui-text--muted b05-route__note"; // 부품 보조 글(553-2) - help.textContent = - "구조물을 설치할 위치를 측점번호+잔여거리로 추가합니다. 목록에서 고르면 수정·삭제할 수 있습니다."; - - // 측점번호/잔여거리를 최상단에 둔다(위치가 먼저, 그다음 구조물 종류·하위 옵션). - body.append(stationRow, field("구조물", typeSelect), subRow, actions, list, help); - - const stations: IrregularStation[] = []; - let editingId: string | null = null; - let nextId = 1; - - function chainageOf(station: number, remainder: number): number { - const interval = callbacks.getInterval(); - return station * (interval > 0 ? interval : 20) + remainder; - } - - /** 누가거리를 측점번호+잔여거리로 되돌린다(외부 주입·이동 공용). */ - function splitChainage(chainageM: number): { station: number; remainder: number } { - const interval = intervalMax(); - const station = Math.floor((chainageM + 1e-6) / interval); - return { - station, - remainder: Number((chainageM - station * interval).toFixed(3)), - }; - } - - function syncButtons(): void { - primary.textContent = editingId ? "수정" : "추가"; - remove.disabled = editingId === null; - } - - function loadForm(target: IrregularStation | null): void { - editingId = target?.id ?? null; - stationField.value = target ? String(target.station) : ""; - remainderField.value = target ? String(target.remainder) : ""; - // 구버전 항목(structureType 없음)은 "기타"로 열고 이름칸에 기존 텍스트를 옮긴다. - // 선택 해제·리셋이면 종류도 빈칸으로 — 하위 옵션이 전부 숨는다(2026-08-05 사용자 지시). - const type = target ? (target.structureType ?? "기타") : ""; - typeSelect.value = type; - pipeTypeSelect.value = target?.pipeType ?? PIPE_DEFAULT_TYPE; - syncDiameterOptions(target?.diameterMm ?? PIPE_DEFAULT_DIAMETER_MM); - widthSelect.value = (target?.escapeWidthM ?? ESCAPE_ROUTE_DEFAULT_WIDTH_M).toFixed(1); - nameField.value = target - ? (target.customName ?? (target.structureType ? "" : target.structure)) - : ""; - // 배수유역 자동 배관(정본 투영)은 관종·직경만 바꿀 수 있다 — 타입 자체는 잠근다 - // (2026-08-05 사용자 지시. 배관을 다른 구조물로 바꾸면 관 지점 정본과 어긋난다). - typeSelect.disabled = !!target && isPipeStation(target); - syncSubOptions(); - syncButtons(); - // 선택 강조(is-selected)가 클릭 즉시 따라오도록 목록을 다시 그린다(토글 해제 포함). - renderList(); - callbacks.onSelect(target ?? null); - } - - function renderList(): void { - list.replaceChildren(); - if (!stations.length) { - // 부품 빈 상태 작은 꼴(553-2) - const empty = document.createElement("li"); - empty.className = "ui-empty ui-empty--small b05-route__irregular-empty"; - const text = document.createElement("p"); - text.className = "ui-empty__text"; - text.textContent = "추가된 비정규 측점이 없습니다."; - empty.append(text); - list.append(empty); - return; - } - [...stations] - .sort((a, b) => a.chainage_m - b.chainage_m) - .forEach((station) => { - const item = document.createElement("li"); - item.className = "b05-route__irregular-item"; - item.classList.toggle("is-selected", station.id === editingId); - const name = document.createElement("strong"); - name.textContent = irregularLabel(station); - const info = document.createElement("span"); - info.textContent = station.structure || "(구조물 미입력)"; - item.append(name, info); - // 같은 항목 재클릭 = 선택 해제(2026-08-04 사용자 지시) — 그래프·3D 강조도 함께 풀린다. - item.addEventListener("click", () => loadForm(station.id === editingId ? null : station)); - list.append(item); - // 목록이 5개를 넘으면 내부 스크롤이 생긴다 — 선택(=수정 중) 항목이 스크롤 밖에 - // 있으면 안 보이므로, 렌더 직후 그 항목만 보이는 위치로 데려온다. - if (station.id === editingId) item.scrollIntoView({ block: "nearest" }); - }); - } - - /** 잔여거리는 측점 간격을 넘을 수 없다(넘으면 다음 측점이 됨). max·검증에 함께 쓴다. */ - function intervalMax(): number { - const interval = callbacks.getInterval(); - return interval > 0 ? interval : 20; - } - - function commit(): void { - const station = Number.parseInt(stationField.value, 10); - const remainder = Number.parseFloat(remainderField.value || "0"); - if (!Number.isFinite(station) || station < 0) { - stationField.focus(); - return; - } - // 잔여거리는 [0, 측점간격] 범위로 클램프한다. - const safeRemainder = Number.isFinite(remainder) - ? Math.min(Math.max(remainder, 0), intervalMax()) - : 0; - // 종류 미선택(빈칸)이면 추가하지 않는다 — 리셋 직후 상태. - if (!typeSelect.value) { - typeSelect.focus(); - return; - } - // 드롭다운 선택값 → 옵션 필드 + 표시 라벨. 종류에 안 쓰는 필드는 넣지 않는다. - const structureType = typeSelect.value as StructureType; - const options: Partial = { structureType }; - if (structureType === "배관") { - options.pipeType = pipeTypeSelect.value; - options.diameterMm = Number(diameterSelect.value) || PIPE_DEFAULT_DIAMETER_MM; - } else if (structureType === "대피로") { - options.escapeWidthM = Number(widthSelect.value) || ESCAPE_ROUTE_DEFAULT_WIDTH_M; - } else if (structureType === "기타") { - options.customName = nameField.value.trim() || STRUCTURE_ETC_DEFAULT_NAME; - } - const structure = structureLabel(options); - const chainage_m = chainageOf(station, safeRemainder); - if (editingId) { - // 그 자리에서 변경(Object.assign)하면 Page가 보관한 이전 목록의 객체도 함께 바뀌어 - // "이동 전 chainage"를 잃는다 → 옛 위치의 계획고·곡선 편집을 정리하지 못한다. - // 새 객체로 교체해, Page가 이전 위치를 감지하고 그 편집을 지우게 한다. - const index = stations.findIndex((entry) => entry.id === editingId); - if (index >= 0) { - const previous = stations[index]; - // 배관 관종·직경을 손으로 바꿨으면 자동 지정이 다시 덮지 않게 표시한다. - const userSized = - structureType === "배관" && - (previous.userSized || - previous.pipeType !== options.pipeType || - previous.diameterMm !== options.diameterMm); - stations[index] = { - ...previous, - id: editingId, - station, - remainder: safeRemainder, - chainage_m, - structure, - ...options, - userSized, - }; - } - } else { - stations.push({ - id: String(nextId++), - station, - remainder: safeRemainder, - chainage_m, - structure, - ...options, - origin: "user", - }); - } - loadForm(null); - renderList(); - callbacks.onChange([...stations]); - } - - // 측점 간격은 옵션에서 바뀔 수 있으므로, 잔여거리의 max를 입력 직전에 현재 간격으로 맞춘다. - const syncRemainderMax = (): void => { - remainderField.max = String(intervalMax()); - }; - remainderField.addEventListener("focus", syncRemainderMax); - remainderField.addEventListener("input", syncRemainderMax); - syncRemainderMax(); - - primary.addEventListener("click", commit); - remove.addEventListener("click", () => { - if (!editingId) return; - const index = stations.findIndex((entry) => entry.id === editingId); - if (index >= 0) stations.splice(index, 1); - loadForm(null); - renderList(); - callbacks.onChange([...stations]); - }); - reset.addEventListener("click", () => loadForm(null)); - - syncButtons(); - renderList(); - - return { - root, - getStations: () => [...stations], - selectByChainage(chainageM) { - if (chainageM === null) { - loadForm(null); - return; - } - const target = stations.find((entry) => Math.abs(entry.chainage_m - chainageM) < 1e-6); - loadForm(target ?? null); - }, - setStations(seed) { - stations.length = 0; - seed.forEach((entry) => { - const { station, remainder } = splitChainage(entry.chainage_m); - stations.push({ - id: String(nextId++), - station, - remainder, - chainage_m: entry.chainage_m, - structure: entry.structure, - origin: "user", - }); - }); - loadForm(null); - renderList(); - callbacks.onChange([...stations]); - }, - setPipeStations(pipes) { - // 배관 항목은 통째로 갈아 끼운다 — 정본은 배수유역도의 관 목록이다. - // 단, 사용자가 관종·직경을 손으로 바꾼 항목(userSized)은 같은 자리에 다시 올 때 유지한다. - const previousPipes = stations.filter(isPipeStation); - const incomingChainages = pipes.map((pipe) => - typeof pipe === "number" ? pipe : pipe.chainage_m, - ); - for (let index = stations.length - 1; index >= 0; index -= 1) { - const entry = stations[index]; - // 경로확정 정본(종단 병합분)에서 복원된 항목은 구조물 종류(structureType)가 없다. - // 그 중 정본 관과 같은 자리(±0.5m)인 것은 관의 **복원 유령**이다 — 지금 투영되는 - // 정본 관이 진짜이므로 걷어낸다. 같은 배관이 목록에 2개씩 뜨던 원인(2026-08-06 - // 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다. - const restoredGhost = - entry.structureType === undefined && - incomingChainages.some( - (chainage) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M, - ); - if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1); - } - pipes.forEach((pipe) => { - const chainage = typeof pipe === "number" ? pipe : pipe.chainage_m; - const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null); - const { station, remainder } = splitChainage(chainage); - const kept = previousPipes.find( - (entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M, - ); - const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE; - const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective); - const entry: IrregularStation = { - id: String(nextId++), - station, - remainder, - chainage_m: chainage, - structure: "", - structureType: PIPE_STRUCTURE_NAME, - pipeType, - diameterMm, - userSized: kept?.userSized ?? false, - origin: "pipe", - }; - entry.structure = structureLabel(entry); - stations.push(entry); - }); - if (editingId && !stations.some((entry) => entry.id === editingId)) loadForm(null); - renderList(); - callbacks.onChange([...stations]); - }, - addStructure(chainageM, structureType) { - const { station, remainder } = splitChainage(chainageM); - const entry: IrregularStation = { - id: String(nextId++), - station, - remainder, - chainage_m: chainageM, - structure: "", - structureType, - origin: "user", - }; - if (structureType === "대피로") entry.escapeWidthM = ESCAPE_ROUTE_DEFAULT_WIDTH_M; - if (structureType === "기타") entry.customName = STRUCTURE_ETC_DEFAULT_NAME; - entry.structure = structureLabel(entry); - stations.push(entry); - renderList(); - callbacks.onChange([...stations]); - }, - removeByChainage(chainageM) { - const index = stations.findIndex( - (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, - ); - if (index < 0) return null; - const [removed] = stations.splice(index, 1); - if (editingId === removed.id) loadForm(null); - renderList(); - callbacks.onChange([...stations]); - return removed; - }, - moveByChainage(fromChainageM, toChainageM) { - const index = stations.findIndex( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, - ); - if (index < 0) return false; - const { station, remainder } = splitChainage(toChainageM); - // 그 자리에서 고치지 않고 새 객체로 교체한다 — Page가 옛 위치의 계획고 편집을 정리해야 한다. - stations[index] = { - ...stations[index], - station, - remainder, - chainage_m: toChainageM, - }; - renderList(); - callbacks.onChange([...stations]); - return true; - }, - clear() { - stations.length = 0; - loadForm(null); - renderList(); - callbacks.onChange([]); - }, - }; -} diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts index d27659b6a..709ca7d57 100644 --- a/B05_Profile/logic/B05_Profile_PlanFootprint.ts +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -608,20 +608,6 @@ export function slopePieces( return { edge: [lines.left_edge_m, lines.right_edge_m], fill: fillLines, cut: cutLines, ticks }; } -/** 측점들 → 평면 발자국(도로 끝이 없는 측점은 뺌). */ -export function planFootprint( - route: Array<[number, number, number]>, - stations: FootprintStation[], - startM: number, - endM: number, - stepM: number = TICK_STEP_M, -): SlopePieces { - const records = stations - .map(footprintRecord) - .filter((record): record is FootprintRecord => record !== null); - return slopePieces(route, records, startM, endM, stepM); -} - /** 계약 `PlanFootprint` + 비탈 끝 점마다 깎기 여부 · 눈금 · 측점 줄(다음 계산이 범위 밖을 재사용). */ export interface FootprintLines extends PlanFootprint { slopeLeftCut: boolean[]; diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts index 5877c0d9c..deff333f8 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts @@ -1,87 +1,15 @@ /* ============================================================================= * B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts - * 종단 계획선 편집 도구 — 구간 평행이동(쉬프트) · 곡선 R 입력. `B05_Profile_UI_Profile_Alignment.ts` + * 종단 계획선 편집 도구 — 곡선 R 입력. `B05_Profile_UI_Profile_Alignment.ts` * 에서 나눔(700줄 · 511-B B-3) · 선형 짓기(`buildAlignment`)는 그 파일. * ========================================================================== */ import { - buildAlignment, chainageKey, - interpolate, - planElevationAt, - type AlignmentBase, type AlignmentCurve, type AlignmentEdits, - type AlignmentSegment, - type ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; -const FEEDBACK_PASSES = 4; -const FEEDBACK_TOLERANCE_M = 1e-4; - -/** - * 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정). - * - * 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트. - * 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽 - * 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고 - * 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간: - * 안쪽 미틸트 측점 2개 미만). - */ -export function shiftMovingPoints( - alignment: ProfileAlignment, - segment: AlignmentSegment, -): [number, number] | null { - const offsets = alignment.edits.station_offsets; - const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined; - const interior = alignment.stations - .map((station) => station.chainage_m) - .filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6); - const left = movable(segment.from_m) ? segment.from_m : interior[0]; - const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1]; - if (left === undefined || right === undefined) return null; - if (right - left < 1e-6) return null; - return [left, right]; -} - -/** - * 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다. - * 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다. - * 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다. - */ -export function shiftSegment( - base: AlignmentBase, - edits: AlignmentEdits, - segment: AlignmentSegment, - delta: number, -): AlignmentEdits { - const current = buildAlignment(base, edits); - const moving = shiftMovingPoints(current, segment); - if (!moving) return edits; - const baseS = base.basePvi.map((node) => node.chainage_m); - const baseZ = base.basePvi.map((node) => node.elevation_m); - const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta); - const offsets = { ...edits.station_offsets }; - moving.forEach((chainage) => { - const key = chainageKey(chainage); - const seed = - offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage); - offsets[key] = Number((seed + delta).toFixed(6)); - }); - for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) { - const trial = buildAlignment(base, { ...edits, station_offsets: offsets }); - let worst = 0; - moving.forEach((chainage, index) => { - const key = chainageKey(chainage); - const error = targets[index] - planElevationAt(trial, chainage); - worst = Math.max(worst, Math.abs(error)); - offsets[key] = Number((offsets[key] + error).toFixed(6)); - }); - if (worst < FEEDBACK_TOLERANCE_M) break; - } - return { ...edits, station_offsets: offsets }; -} - /** * 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는 * L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다). diff --git a/B05_Profile/logic/B05_Profile_Util_Station.ts b/B05_Profile/logic/B05_Profile_Util_Station.ts index 26dc58588..d9c2eae22 100644 --- a/B05_Profile/logic/B05_Profile_Util_Station.ts +++ b/B05_Profile/logic/B05_Profile_Util_Station.ts @@ -33,24 +33,3 @@ export function chainageToStation( } return { station, remainder }; } - -/** 구조물 측점과 겹쳤다고 볼 거리(m) — 이보다 가까우면 규칙 측점을 지운다. */ -export const STATION_MERGE_TOLERANCE_M = 0.1; - -/** - * 구조물 측점과 **겹치는 규칙 측점을 지운다**(2026-09-06). - * - * 구조물은 보조말뚝을 박아 그 자리가 정본 측점이 된다. 규칙 격자(20m)와 0.1m 안에서 - * 만나면 세로선·라벨이 두 겹으로 겹쳐 읽히지 않으므로 구조물 쪽만 남긴다. - */ -export function dropStationsNear( - stations: readonly T[], - anchors: ReadonlyArray<{ chainage_m: number }>, - toleranceM: number = STATION_MERGE_TOLERANCE_M, -): T[] { - if (anchors.length === 0) return [...stations]; - return stations.filter( - (station) => - !anchors.some((anchor) => Math.abs(anchor.chainage_m - station.chainage_m) <= toleranceM), - ); -} diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 90c9de30e..8463a4b64 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -52,8 +52,6 @@ export function setGeneralStations( listed = (stations ?? []).map((station) => station.chainage_m); } -export const generalStations = (): readonly number[] => general; - let plusSource: () => readonly number[] = () => []; /** 추가 측점 읽는 곳을 둔다(368-2) — 부를 때마다 읽음(되돌리기 · 다른 보기가 바꿔도 같은 값). */ diff --git a/common_util/common_util_summary_item.ts b/common_util/common_util_summary_item.ts index b6bb9fe80..d30d08b72 100644 --- a/common_util/common_util_summary_item.ts +++ b/common_util/common_util_summary_item.ts @@ -34,12 +34,6 @@ export function isSummaryItem(value: unknown): value is SummaryItem { ); } -/** 같은 항목인가 — 층 · 주인 · 키가 같으면 같음(이름은 바뀔 수 있어 안 봄). */ -export function sameSummaryItem(left: SummaryItem | null, right: SummaryItem | null): boolean { - if (!left || !right) return left === right; - return left.layer === right.layer && left.owner === right.owner && left.key === right.key; -} - /** 서버 · 세션에서 온 값을 참조 또는 null 로 — 모양이 틀리면 null(미지정). */ export function readSummaryItem(value: unknown): SummaryItem | null { return isSummaryItem(value) ? value : null; diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs index 2d68c1598..293d5be19 100644 --- a/resources/tester/helper_234_1_structure_station.cjs +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -73,7 +73,6 @@ station.setGeneralStations( { chainage_m: 80.17, kind: "irregular" }, ]), ); -out.general = [...station.generalStations()]; out.defaultSnap = station.snapStructureStation(80.4); // ② 좌측 폼 칸 — 친 값 80.17 → 80 · 안 고친 옛 값은 그대로 diff --git a/resources/tester/helper_290_1_project_purge.cjs b/resources/tester/helper_290_1_project_purge.cjs index df77196ae..4ffed7fc3 100644 --- a/resources/tester/helper_290_1_project_purge.cjs +++ b/resources/tester/helper_290_1_project_purge.cjs @@ -47,7 +47,6 @@ const C = "cccc3333"; const SEED = { [`aislo:draft:structures:${A}`]: '[{"id":1}]', [`aislo:draft:crossw:${A}:12`]: '{"10":1}', - [`b06:culvertopt:${A}:12`]: '{"x":1}', // 옛 키(초안) [`aislo:result:latest:${A}`]: '{"r":1}', [`aislo:draft:structures:${B}`]: '[{"id":2}]', [`aislo:result:section-detail:${B}:7`]: '{"d":1}', @@ -55,8 +54,6 @@ const SEED = { [`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음 [`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸 "aislo.otp-pending": "x", // 프로젝트와 무관 - [`d02_points_${A}`]: '{"base":1}', // D02 옛 키(401 · 등록표 옛 키 머리로 잡음) - [`e03_prob_cache:${C}`]: "{}", // E 옛 키 · 빈 값 = 고침 없음 }; const seed = (extra = {}) => { global.window.sessionStorage = fakeStorage(); diff --git a/resources/tester/helper_344_4_design_pipeline.cjs b/resources/tester/helper_344_4_design_pipeline.cjs index b2b9ce1c3..25a3d3fb5 100644 --- a/resources/tester/helper_344_4_design_pipeline.cjs +++ b/resources/tester/helper_344_4_design_pipeline.cjs @@ -302,8 +302,6 @@ async function run() { await tick(storeMod.PERSIST_DELAY_MS + 50); const afterDelay = JSON.parse(session.getItem(key("design-stations"))); store.set("profile", { schema_version: 1 }); - storeMod.flushDesignStore(); - const flushed = JSON.parse(session.getItem(key("design-profile"))); store.set("generation", 7); const generationSession = [...Array(session.length).keys()] .map((i) => session.key(i)) @@ -336,7 +334,6 @@ async function run() { beforeFlush, memoryRead, afterDelay, - flushed, generation: store.get("generation"), generationSession, cleared, diff --git a/resources/tester/helper_401_project_isolation.cjs b/resources/tester/helper_401_project_isolation.cjs index a086d0226..11946b666 100644 --- a/resources/tester/helper_401_project_isolation.cjs +++ b/resources/tester/helper_401_project_isolation.cjs @@ -115,16 +115,9 @@ const keys = (win) => Object.keys(win.sessionStorage.dump()).sort(); `aislo:draft:structures:${X}`, `aislo:draft:crossw:${X}:12`, `aislo:result:section-detail:${X}:12`, - `b06:culvertopt:${X}:12`, `aislo:draft:d03-plan:${X}`, `aislo:draft:d04-assignees:${X}`, `aislo:draft:e05-report:${X}`, - `d02_points_${X}`, - `e02_ground_cache:${X}`, - `e03_prob_cache:${X}`, - `e04_settings:${X}`, - `e06_rock_cache:${X}`, - `e07_settings:${X}`, ]; const keep = [ `aislo:draft:structures:${Y}`, diff --git a/resources/tester/helper_b05_save_payload.mjs b/resources/tester/helper_b05_save_payload.mjs deleted file mode 100644 index 08d24b21e..000000000 --- a/resources/tester/helper_b05_save_payload.mjs +++ /dev/null @@ -1,111 +0,0 @@ -// 저장 왕복 시험용 — 화면 TS 를 node 로 묶어 저장 본문을 만든다. -// node helper_b05_save_payload.mjs <저장소 뿌리> · 표준입력 = 작업 목록(JSON) · 표준출력 끝 줄 = 결과 목록 -// {op:"attach", entries:[관 응답], moves:[[옛, 새]], points:[{chainage_m, source}]} → attach 결과 -// {op:"station", chainage, interval} → 구조물 측점 칸에 쓰고 다시 읽은 값 -// {op:"culvert", server:[관 응답], pending:{측점:조각}, moves:{측점:새 측점}} → PUT 본문 points 또는 null -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; - -const root = path.resolve(process.argv[2] ?? "."); -const { build } = await import( - pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href -); - -// 화면 칸 · 세션만 흉내 — 측점 칸은 값 · 클래스 · 이벤트만 쓴다 -class Node { - constructor() { - this.value = ""; - this.children = []; - this.classList = { toggle() {}, add() {}, remove() {} }; - this.style = {}; - this.dataset = {}; - } - append(...kids) { - this.children.push(...kids); - } - appendChild(kid) { - this.children.push(kid); - return kid; - } - addEventListener() {} - setAttribute() {} -} -const session = new Map(); -globalThis.document = { createElement: () => new Node(), createTextNode: () => new Node() }; -globalThis.window = { - sessionStorage: { - getItem: (k) => (session.has(k) ? session.get(k) : null), - setItem: (k, v) => session.set(k, String(v)), - removeItem: (k) => session.delete(k), - }, - location: { origin: "http://localhost" }, - setTimeout: (...a) => setTimeout(...a), - clearTimeout: (id) => clearTimeout(id), -}; -globalThis.sessionStorage = globalThis.window.sessionStorage; -globalThis.localStorage = globalThis.window.sessionStorage; - -const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-save-payload-")); -try { - await build({ - configFile: false, - logLevel: "silent", - root, - resolve: { - alias: { - "@ui": path.join(root, "ui_template"), - "@config": path.join(root, "config"), - "@util": path.join(root, "common_util"), - }, - }, - build: { - outDir, - minify: false, - target: "es2022", - lib: { - entry: path.join(root, "resources/tester/helper_b05_save_payload_entry.ts"), - formats: ["es"], - fileName: "entry", - }, - }, - }); - const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href); - const jobs = JSON.parse(fs.readFileSync(0, "utf-8")); - const results = []; - for (const job of jobs) { - if (job.op === "attach") { - const store = mod.createFacilityStore(); - store.replaceFromResponse(job.entries); - // 지도에서 끈 관 — 화면이 끌기를 마치면 속성을 새 측점으로 옮김(onMove) - for (const [from, to] of job.moves ?? []) store.move(from, to); - results.push(store.attach(job.points)); - } else if (job.op === "station") { - const fields = mod.stationFields("측점", () => job.interval); - fields.write(job.chainage, job.interval); - results.push(fields.read(job.interval, true)); - } else if (job.op === "culvert") { - session.clear(); - session.set("opt", JSON.stringify(job.pending ?? {})); - session.set("move", JSON.stringify(job.moves ?? {})); - let sent = null; - globalThis.fetch = async (_url, init = {}) => { - const body = init.method === "PUT" ? JSON.parse(init.body) : null; - if (body) sent = body.points; - const payload = body ? { pipe_points: body.points } : { pipe_points: job.server }; - return new Response(JSON.stringify(payload), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }; - await mod.flushCulvertOptions("p", "opt", "move"); - results.push(sent); - } else { - throw new Error(`모르는 작업 ${job.op}`); - } - } - console.log(JSON.stringify(results)); -} finally { - fs.rmSync(outDir, { recursive: true, force: true }); -} diff --git a/resources/tester/helper_b05_save_payload_entry.ts b/resources/tester/helper_b05_save_payload_entry.ts deleted file mode 100644 index e2d102bdc..000000000 --- a/resources/tester/helper_b05_save_payload_entry.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* ============================================================================= - * helper_b05_save_payload_entry.ts - * 저장 왕복 시험(`test_b05_b06_save_roundtrip.py`)이 **화면 코드 그대로** 저장 본문을 만들 때 - * 묶는 진입점 — 관 시설 되붙이기 · 구조물 측점 칸(표시 → 다시 읽기) · 구간값 예약 흘려보내기. - * ========================================================================== */ - -export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility"; -export { stationFields } from "../../B05_Profile/B05_Profile_UI_Structures_Fields"; -export { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 8338d6767..4c7ff7c35 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -177,12 +177,6 @@ out.isItem = [ item.isSummaryItem({ ...A, key: "" }), ]; out.readItem = [item.readSummaryItem(A)?.key ?? null, item.readSummaryItem("nope")]; -out.same = [ - item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }), - item.sameSummaryItem(A, B), - item.sameSummaryItem(null, null), - item.sameSummaryItem(A, null), -]; out.label = [item.summaryLabel(null), item.summaryLabel(A)]; out.tip = [item.summaryTooltip(null), item.summaryTooltip(A)]; diff --git a/resources/tester/test_234_1_structure_station.py b/resources/tester/test_234_1_structure_station.py index 4bbf71e9a..cd0cb5f5e 100644 --- a/resources/tester/test_234_1_structure_station.py +++ b/resources/tester/test_234_1_structure_station.py @@ -128,8 +128,7 @@ def test_arrow_step_one_metre(ts) -> None: def test_general_registry_skips_irregular(ts) -> None: - assert ts["general"] == STATIONS # 구조물 측점 80.17(irregular)은 일반 측점 아님 - assert ts["defaultSnap"] == 80 + assert ts["defaultSnap"] == 80 # 구조물 측점 80.17(irregular)은 일반 측점 아님 def test_form_input_integer(ts) -> None: diff --git a/resources/tester/test_290_1_project_purge.py b/resources/tester/test_290_1_project_purge.py index d23b0ecd9..4d6c410a4 100644 --- a/resources/tester/test_290_1_project_purge.py +++ b/resources/tester/test_290_1_project_purge.py @@ -45,8 +45,6 @@ def test_남기기_그_프로젝트_초안만_둠(out): kept = [ f"aislo:draft:crossw:{A}:12", f"aislo:draft:structures:{A}", - f"b06:culvertopt:{A}:12", - f"d02_points_{A}", # D02 옛 키도 초안(401) ] assert out["keep"]["keys"] == sorted(MINE + UNRELATED + kept) assert out["keep"]["asked"] == 1 @@ -54,7 +52,7 @@ def test_남기기_그_프로젝트_초안만_둠(out): def test_같은_프로젝트_다시_들어오면_안_지움(out): assert out["same"]["asked"] == 0 and out["same"]["purged"] == 0 - assert len(out["same"]["keys"]) == 13 + assert len(out["same"]["keys"]) == 10 def test_빈_초안은_고침_아님_취향은_둠(out): diff --git a/resources/tester/test_344_4_design_pipeline.py b/resources/tester/test_344_4_design_pipeline.py index a39490257..584a74a1d 100644 --- a/resources/tester/test_344_4_design_pipeline.py +++ b/resources/tester/test_344_4_design_pipeline.py @@ -141,7 +141,6 @@ def test_가게_결과_칸_메모리_우선(r): s = r["store"] assert s["beforeFlush"] is None and s["memoryRead"] is True # 끌기마다 세션에 안 씀 assert s["afterDelay"] == [{"key": "0", "chainageM": 0}] # 멈춘 뒤 한 번 - assert s["flushed"] == {"schema_version": 1} assert s["generationSession"] == [] # 차례 번호는 메모리만 assert s["cleared"] == {"stations": None, "profile": None} # clearResults = 메모리도 버림 assert s["footprintRevived"] is None # 지운 뒤 미뤄 둔 쓰기가 되살리지 않음 diff --git a/resources/tester/test_346_2_plan_footprint_mirror.py b/resources/tester/test_346_2_plan_footprint_mirror.py index 9be348692..1f121ef45 100644 --- a/resources/tester/test_346_2_plan_footprint_mirror.py +++ b/resources/tester/test_346_2_plan_footprint_mirror.py @@ -128,10 +128,10 @@ def _py_record(station: dict) -> dict | None: DRIVER = """ import { readFileSync, writeFileSync } from "node:fs"; -import { planFootprint } from "./B05_Profile_PlanFootprint.js"; +import { footprintRecord, slopePieces } from "./B05_Profile_PlanFootprint.js"; const cases = JSON.parse(readFileSync(process.argv[2], "utf-8")); writeFileSync(process.argv[3], JSON.stringify(cases.map((c) => - planFootprint(c.route, c.stations, c.start, c.end, c.step)))); + slopePieces(c.route, c.stations.map(footprintRecord).filter(Boolean), c.start, c.end, c.step)))); """ CASES = [(0.0, 400.0, 2.0), (35.0, 130.0, 2.0), (0.0, 400.0, 10.0)] diff --git a/resources/tester/test_384_1_session_keys.py b/resources/tester/test_384_1_session_keys.py index c1ea1c7dd..2020a8014 100644 --- a/resources/tester/test_384_1_session_keys.py +++ b/resources/tester/test_384_1_session_keys.py @@ -73,5 +73,5 @@ def test_등록부_옛_키는_프로젝트_품음_사진_칸은_그_프로젝트 ) # fmt: skip assert run.returncode == 0, run.stderr out = json.loads(run.stdout) - assert out["legacy"] and all("PX" in key for _, key in out["legacy"]) + assert out["legacy"] == [] # 옛 키 옮김 지움(472-8) assert out["keys"] == ["aislo:draft:crossw:p1:7", "aislo:draft:pipes:p1"] diff --git a/resources/tester/test_401_project_isolation.py b/resources/tester/test_401_project_isolation.py index 757f14daa..7a3b677d7 100644 --- a/resources/tester/test_401_project_isolation.py +++ b/resources/tester/test_401_project_isolation.py @@ -47,12 +47,12 @@ def test_다른_프로젝트_화면은_새로_엶(out): def test_떠남은_그_프로젝트_몫만_지움(out): leave = out["leave"] - assert leave["keys"] == leave["expected"] # B · D · E · 옛 키 · 준비 표식 0 · 밖 칸 그대로 + assert leave["keys"] == leave["expected"] # B · D · E · 준비 표식 0 · 밖 칸 그대로 assert leave["entered"] == X # 탭 프로젝트 표식은 남김 assert leave["local"] == ["1", "1"] # 취향 · CAD 복구본 assert leave["idb"] == [f"{Y}|u1"] # 보관함 = X 줄만 지움 assert leave["otherTabKept"] == "1" # 다른 탭 세션은 안 건드림 - assert leave["notified"] == 13 # 지운 칸마다 지움 알림 — 설계 가게가 미룬 쓰기(1 s)를 거둠 + assert leave["notified"] == 6 # 지운 칸마다 지움 알림 — 설계 가게가 미룬 쓰기(1 s)를 거둠 def test_떠남_물음_한_번(out): diff --git a/resources/tester/test_b05_b06_save_roundtrip.py b/resources/tester/test_b05_b06_save_roundtrip.py deleted file mode 100644 index 05b8a6ea9..000000000 --- a/resources/tester/test_b05_b06_save_roundtrip.py +++ /dev/null @@ -1,518 +0,0 @@ -"""B05 · B06 저장 왕복 — 사용자가 바꾼 값이 저장 · 다시 읽기 뒤 그대로인가(PLAN 19장). - -환경 배정 storage 프로젝트(랩탑_보조)의 **사본**에서 화면 저장 흐름과 같은 차례로 서버 API 를 -부르고, 다시 읽은 값(pipe_points.json · structures.json · 종단 파일 · 응답)을 사용자가 고른 값과 -0 차이로 대조. 저장 본문은 화면 코드 그대로(`helper_b05_save_payload.mjs` — 시설 되붙이기 · -구조물 측점 칸 · 구간값 예약). - -- 파일: 프로젝트 폴더를 임시 폴더로 복사(큰 원본 · 점군 제외) · `STORAGE_BASE_DIR` 를 그리로 돌림. -- DB: 읽기는 진짜 DB(같은 프로젝트 행) · 세션을 읽기 전용으로 열어 쓰기가 새지 않음 · - 쓰는 함수는 받은 값을 적어 두는 가짜로 갈음(DB 에 남기는 값도 보낸 값과 대조). -- 원본 프로젝트 · 사용자 프로젝트 · DB 는 바뀌지 않음. -""" - -from __future__ import annotations - -import json -import shutil -import subprocess -from contextlib import asynccontextmanager -from pathlib import Path -from typing import Any - -import pytest - -from B05_Profile.B05_Profile_Engine_Sections import cross_filename - -ROOT = Path(__file__).resolve().parents[2] -PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조` -SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID -# 저장 흐름이 읽고 쓰는 것만 — 원본 입력(B03) · 점군(structured.npz 등)은 안 씀 -_COPY_DIRS = ("B04_PreProcess/drainage", "B05_Profile", "B06_Section") -_COPY_MAX_BYTES = 20 * 1024 * 1024 - -pytestmark = pytest.mark.skipif(not SOURCE.is_dir(), reason="배정 storage 프로젝트가 없음") - - -def _copy_project(dest: Path) -> None: - for rel in _COPY_DIRS: - shutil.copytree(SOURCE / rel, dest / rel) - for rel in ("B04_PreProcess/models", "B04_PreProcess/processed"): - (dest / rel).mkdir(parents=True, exist_ok=True) - for item in (SOURCE / rel).iterdir(): - if item.is_file() and item.stat().st_size <= _COPY_MAX_BYTES: - shutil.copy2(item, dest / rel / item.name) - for item in SOURCE.iterdir(): - if item.is_file(): - shutil.copy2(item, dest / item.name) - - -class Recorder: - """DB 에 쓰는 함수 대신 — 받은 값을 적어 둠.""" - - def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, Any]]] = [] - - def fake(self, name: str, result: Any = None): - async def _fake(*args: Any, **kwargs: Any) -> Any: - self.calls.append((name, {"args": args[1:], **kwargs})) - return result - - return _fake - - def of(self, name: str) -> list[dict[str, Any]]: - return [call for key, call in self.calls if key == name] - - -@pytest.fixture() -def api(tmp_path, monkeypatch): - aiomysql = pytest.importorskip("aiomysql") - from fastapi import FastAPI - from fastapi.testclient import TestClient - - import B04_PreProcess.B04_PreProcess_Router_Basins as basins - import B05_Profile.B05_Profile_Router as route - import B05_Profile.B05_Profile_Router_Confirm as route_confirm - import B05_Profile.B05_Profile_Router_Lifecycle as lifecycle - import B05_Profile.B05_Profile_Structures_Router as structures - import B06_Section.B06_Section_Repository_Bulk as bulk - import B06_Section.B06_Section_Router_Confirm as sections - import B06_Section.B06_Section_Router_Stations as station_router - import common_util.common_util_storage as storage - import config.config_db as config_db - from config import config_system as cfg - - storage_root = tmp_path / "storage" - _copy_project(storage_root / "1" / "3" / PROJECT_ID) - monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(storage_root)) - - rec = Recorder() - monkeypatch.setattr(route, "update_longitudinal_grade_summary", rec.fake("grade_summary")) - monkeypatch.setattr(structures, "_invalidate_downstream", rec.fake("invalidate", True)) - monkeypatch.setattr(lifecycle, "confirm_route", rec.fake("confirm_route")) - monkeypatch.setattr(lifecycle, "complete_stage", rec.fake("complete_stage")) - monkeypatch.setattr(bulk, "merge_cross_section_designs", rec.fake("designs", 0)) - monkeypatch.setattr(sections, "merge_cross_section_designs", rec.fake("designs", 0)) - monkeypatch.setattr(sections, "merge_longitudinal_section_options", rec.fake("options")) - monkeypatch.setattr(sections, "merge_longitudinal_section_data", rec.fake("data")) - monkeypatch.setattr(sections, "confirm_sections_for_route", rec.fake("confirm_sections")) - monkeypatch.setattr(sections, "confirm_route_status", rec.fake("confirm_route")) - monkeypatch.setattr(sections, "complete_stage", rec.fake("complete_stage")) - monkeypatch.setattr(sections, "start_stage", rec.fake("start_stage")) - monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute", True)) - # 272 — 측점을 다시 맞춘 뒤 정본에 없는 측점의 횡단 행 지우기 - monkeypatch.setattr(route_confirm, "sync_cross_section_rows", rec.fake("sync_rows", 0)) - monkeypatch.setattr( - route_confirm, "update_longitudinal_grade_summary", rec.fake("grade_summary") - ) - monkeypatch.setattr( - station_router, "update_longitudinal_grade_summary", rec.fake("grade_summary") - ) - real_known = sections.get_cross_section_chainages - - async def spy_known(connection, route_id): - rows = await real_known(connection, route_id) - rec.calls.append(("known", {"chainages": list(rows)})) - return rows - - monkeypatch.setattr(sections, "get_cross_section_chainages", spy_known) - - @asynccontextmanager - async def lifespan(_app): - try: - pool = await aiomysql.create_pool( - host=cfg.DB_HOST, - port=cfg.DB_PORT, - db=cfg.DB_NAME, - user=cfg.DB_USER, - password=cfg.DB_PASSWORD, - minsize=1, - maxsize=6, - autocommit=True, - charset="utf8mb4", - # 읽기 전용 세션 — 가짜로 못 갈음한 쓰기가 있으면 DB 를 바꾸지 않고 터짐 - init_command="SET time_zone='+00:00', SESSION tx_read_only=1", - ) - except Exception as exc: # DB 가 안 닿는 곳 - pytest.skip(f"DB 에 안 닿음: {exc}") - config_db.db_pool = pool - try: - yield - finally: - config_db.db_pool = None - pool.close() - await pool.wait_closed() - - app = FastAPI(lifespan=lifespan) - for module in (basins, route, lifecycle, structures, sections): - app.include_router(module.router) - with TestClient(app) as client: - client.rec = rec # type: ignore[attr-defined] - client.root = storage_root / "1" / "3" / PROJECT_ID # type: ignore[attr-defined] - yield client - - -# ── 화면 코드 그대로 본문 만들기(node) ──────────────────────────────────────── -HELPER = Path(__file__).with_name("helper_b05_save_payload.mjs") -STATION_INTERVAL_M = 20.0 -# 화면이 관 옵션 수정 끝에 측점을 다시 읽어 이만큼 넘게 다르면 관을 옮김 -# (`B05_Profile_UI_Drainage_Panel.ts` `updatePipeFacility`) -PIPE_MOVE_THRESHOLD_M = 0.005 - - -def _screen(jobs: list[dict[str, Any]]) -> list[Any]: - if shutil.which("node") is None: - pytest.skip("node 가 없음") - out = subprocess.run( - ["node", str(HELPER), str(ROOT)], - input=json.dumps(jobs), - cwd=ROOT, - capture_output=True, - text=True, - encoding="utf-8", - timeout=300, - check=True, - ) - return json.loads(out.stdout.strip().splitlines()[-1]) - - -# ── 비교 모양 ───────────────────────────────────────────────────────────────── -LONGITUDINAL = "B06_Section/longitudinal/longitudinal.json" -PIPES_FILE = "B04_PreProcess/drainage/edits/pipe_points.json" -STRUCTURES_FILE = "B05_Profile/route/structures.json" - - -def _key(chainage: float) -> str: - return f"{chainage:.2f}" - - -def _pipes_view(points: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: - """관 — 측점(0.01) → 시설 · 구간 · 옵션(좌표 · 생성 사유는 뺌).""" - return { - _key(p["chainage_m"]): { - "facility": p.get("facility", "pipe"), - "span": (p.get("start_m"), p.get("end_m")), - "options": p.get("options") or {}, - } - for p in points - } - - -def _read(root: Path, rel: str) -> Any: - return json.loads((root / rel).read_text(encoding="utf-8")) - - -def _strip(point: dict[str, Any]) -> dict[str, Any]: - """화면이 서버에 되보내는 관 — 좌표는 서버가 다시 냄(`DetailPipeInput`).""" - return {k: v for k, v in point.items() if k != "lonlat"} - - -class Flow: - """화면 저장 흐름 하나(B05 [임시저장] · B06 [저장]). - - 사용자가 고른 값(`want_*`)을 따로 적어 두고, 화면 코드가 만든 본문을 서버에 보낸 뒤 - 다시 읽은 값을 고른 값과 대조. 바꿀 대상은 사본에 있는 것 중 자리 순번으로 고름. - """ - - def __init__(self, api) -> None: - self.api = api - self.base = f"/api/projects/{PROJECT_ID}" - latest = api.get(f"{self.base}/route/latest").json() - self.route_id = latest["route"]["id"] - self.surface = latest["surface_params"] - self.model_id = latest["route"]["surface_model_id"] - self.diffs: list[str] = [] - self.sent_irregular: list[float] = [] - self._plan() - - def server_pipes(self) -> list[dict[str, Any]]: - got = self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"] - return [_strip(p) for p in got] - - # ── 사용자가 고른 값 ── - def _plan(self) -> None: - pipes = self.server_pipes() - if len(pipes) < 9: - pytest.skip(f"관이 {len(pipes)} 개 — 시험에 9 개 넘게 필요") - self.pipes0 = pipes - ch = [p["chainage_m"] for p in pipes] - self.pipe_move = (ch[2], round(ch[2] + 5.35, 2)) # 관 옮김 — 시설 · 옵션이 따라가야 함 - self.pipe_option = (ch[5], {"pipe_diameter_mm": 1350}) # 옵션만 — 자리 그대로 - self.culvert = (ch[8], {"pipe_diameter_mm": 1200}) # B06 조정창 구간값 예약 - self.stale_move = (ch[4], round(ch[4] + 4.76, 2)) # 옛 세션에 남은 관 이동 예약 - self.pipe_new = round((ch[6] + ch[7]) / 2, 2) # 새 관 하나(맨 배관) - want = [] - for p in pipes: - item = dict(p) - if item["chainage_m"] == self.pipe_move[0]: - item.update(chainage_m=self.pipe_move[1], source="user") - elif item["chainage_m"] == self.pipe_option[0]: - item["options"] = {**(item.get("options") or {}), **self.pipe_option[1]} - elif item["chainage_m"] == self.culvert[0]: - item["options"] = {**(item.get("options") or {}), **self.culvert[1]} - want.append(item) - want.append({"chainage_m": self.pipe_new, "source": "user"}) - self.want_pipes = sorted(want, key=lambda x: x["chainage_m"]) - - got = self.api.get(f"{self.base}/route/structures").json() - self.revision0, items = got["revision"], got["structures"] - spans = [s for s in items if s.get("start_m") is not None and s.get("end_m") is not None] - if len(spans) < 3: - pytest.skip("구간 구조물이 3 개 넘게 필요") - moved, optioned, memoed = spans[0], spans[1], spans[2] - width = moved["end_m"] - moved["start_m"] - to = round(moved["start_m"] + 1.37, 2) # 측점 칸에 소수 2자리로 쳐 넣음 - self.struct_move = (moved["structure_id"], to, round(to + width, 2)) - self.want_structures = json.loads(json.dumps(items)) - for s in self.want_structures: - if s["structure_id"] == moved["structure_id"]: - s.update(chainage_m=to, start_m=to, end_m=round(to + width, 2)) - s["memo"] = "옮긴 뒤 메모" # 옮긴 칸에서 메모만 한 번 더 고침 - elif s["structure_id"] == optioned["structure_id"]: - s["options"] = {**s["options"], "height_m": 3} - s["memo"] = "왕복 시험 메모" - elif s["structure_id"] == memoed["structure_id"]: - s["memo"] = "메모만 바꿈" - - lon = _read(self.api.root, LONGITUDINAL) - grid = [s for s in lon["stations"] if s.get("kind") == "regular"] - g0, g1 = grid[4], grid[len(grid) // 2] - self.offsets = {f"{g0['chainage_m']:.3f}": 0.35, f"{g1['chainage_m']:.3f}": -0.2} - side = grid[3].get("uphill_side") - self.uphill = (grid[3]["chainage_m"], "left" if side != "left" else "right") - self.cross_patches = [ - { - "chainage_m": g0["chainage_m"], - "rock_boundary_offset_m": -0.8, - "cut_slope_ratio_user": 0.5, - }, - {"chainage_m": self.pipe_move[1], "pipe_length_m": 9.0}, - ] - - # ── 화면 코드가 만드는 본문 ── - def b05_pipe_payload(self) -> list[dict[str, Any]]: - """B05 `savePipes` — 화면 관 목록(측점 · 사유) + 시설 보관소 `attach`.""" - (station,) = _screen( - [{"op": "station", "chainage": self.pipe_option[0], "interval": STATION_INTERVAL_M}] - ) - entries = [dict(p) for p in self.pipes0] - screen_ch = {} - for p in self.want_pipes: - c = p["chainage_m"] - if c == self.pipe_option[0] and abs(station - c) > PIPE_MOVE_THRESHOLD_M: - c = station # 옵션 폼 확정이 측점을 표시값(0.1)에서 다시 읽어 관을 옮김 - screen_ch[p["chainage_m"]] = c - for e in entries: # 폼에서 고친 시설 정보는 그 관의 (새) 자리로 보관소에 들어감 - if e["chainage_m"] == self.pipe_option[0]: - e["chainage_m"] = screen_ch[self.pipe_option[0]] - e["options"] = {**(e.get("options") or {}), **self.pipe_option[1]} - points = [ - { - "chainage_m": screen_ch[p["chainage_m"]], - "source": "confirmed" if p.get("source") == "user" else p.get("source", "user"), - } - for p in self.want_pipes - ] - job = {"op": "attach", "entries": entries, "moves": [self.pipe_move], "points": points} - (attached,) = _screen([job]) - return attached - - def b06_pipe_payload(self) -> list[dict[str, Any]]: - """B06 `persistPipes` — 화면 관 상태 그대로(구간값은 예약으로 따로 나감).""" - before = {p["chainage_m"]: p.get("options") for p in self.pipes0} - out = [] - for p in self.want_pipes: - item = dict(p) - if p["chainage_m"] == self.culvert[0]: - item.pop("options", None) - if before[self.culvert[0]]: - item["options"] = before[self.culvert[0]] - out.append(item) - return out - - def screen_structures(self, *, reread: bool) -> list[dict[str, Any]]: - """구조물 — 옮긴 칸에서 메모를 고치면 `commit` 이 측점 칸(표시 0.1)을 다시 읽음.""" - items = json.loads(json.dumps(self.want_structures)) - if not reread: - return items - sid, start, end = self.struct_move - ch = next(s for s in items if s["structure_id"] == sid) - a, b, c = _screen( - [ - {"op": "station", "chainage": v, "interval": STATION_INTERVAL_M} - for v in (start, ch["chainage_m"], end) - ] - ) - ch.update(start_m=a, chainage_m=b, end_m=c) - return items - - def culvert_payload(self, current: list[dict[str, Any]]) -> list[dict[str, Any]] | None: - """`flushCulvertOptions` — 구간값 예약 + 옛 세션 이동 예약을 서버 목록에 얹음.""" - pending = {_key(self.culvert[0]): self.culvert[1]} - moves = {_key(self.stale_move[0]): self.stale_move[1]} - (body,) = _screen( - [{"op": "culvert", "server": current, "pending": pending, "moves": moves}] - ) - return body - - # ── API(화면 Api_Fetch 와 같은 경로 · 본문) ── - def put_pipes(self, pipes: list[dict[str, Any]]) -> None: - res = self.api.put(f"{self.base}/drainage/pipe-points", json={"points": pipes}) - assert res.status_code == 200, res.text - - def flush_culvert(self) -> None: - body = self.culvert_payload( - self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"] - ) - if body is not None: - self.put_pipes(body) - - def put_alignment(self) -> None: - body = {"route_id": self.route_id, "station_offsets": self.offsets, "curve_radii": {}} - res = self.api.put(f"{self.base}/route/profile-alignment", json=body) - assert res.status_code == 200, res.text - - def put_structures(self, items: list[dict[str, Any]]) -> None: - revision = self.api.get(f"{self.base}/route/structures").json()["revision"] - body = {"base_revision": revision, "structures": items} - res = self.api.put(f"{self.base}/route/structures", json=body) - assert res.status_code == 200, res.text - - def confirm(self, *, with_irregular: bool) -> None: - body: dict[str, Any] = { - "uphill_overrides": [{"chainage_m": self.uphill[0], "side": self.uphill[1]}] - } - if with_irregular: # B05 — 관 투영(물넘이포장 뺌) · A군 구조물 - self.sent_irregular = [ - p["chainage_m"] for p in self.server_pipes() if p.get("facility") != "ford_pavement" - ] - body.update( - filter_key=self.surface["source_filter"], - method=self.surface["method"], - smooth=self.surface["smooth"], - surface_model_id=self.model_id, - irregular_stations=[ - {"chainage_m": c, "structure": "배관"} for c in self.sent_irregular - ], - ) - res = self.api.post(f"{self.base}/route/confirm?mark_stage_complete=false", json=body) - assert res.status_code == 200, res.text - - def save_sections(self) -> None: - body = {"cross_patches": self.cross_patches} - res = self.api.post(f"{self.base}/sections/{self.route_id}/save", json=body) - assert res.status_code == 200, res.text - - # ── 다시 읽기 · 대조(사용자가 고른 값 기준) ── - def check(self) -> None: - root, d = self.api.root, self.diffs - want = _pipes_view(self.want_pipes) - for where, got in ( - ("응답", _pipes_view(self.server_pipes())), - ("pipe_points.json", _pipes_view(_read(root, PIPES_FILE)["points"])), - ): - for k in sorted(set(want) | set(got), key=float): - if want.get(k) != got.get(k): - d.append(f"관 {k} ({where}) — 고름 {want.get(k)} · 읽음 {got.get(k)}") - saved = {s["structure_id"]: s for s in _read(root, STRUCTURES_FILE)["structures"]} - for s in self.want_structures: - got_s = saved.get(s["structure_id"], {}) - for field in ("chainage_m", "start_m", "end_m", "options", "memo", "placement_source"): - if s.get(field) != got_s.get(field): - d.append( - f"구조물 {s['type_id']} {field} — " - f"고름 {s.get(field)!r} · 읽음 {got_s.get(field)!r}" - ) - lon = _read(root, LONGITUDINAL) - edits = (lon["profile_alignment"].get("edits") or {}).get("station_offsets") or {} - if edits != self.offsets: - d.append(f"계획고 수정 — 고름 {self.offsets} · 읽음 {edits}") - up = next((s for s in lon["stations"] if abs(s["chainage_m"] - self.uphill[0]) < 0.005), {}) - if (up.get("uphill_side"), up.get("uphill_side_source")) != (self.uphill[1], "user"): - d.append( - f"상단측 {self.uphill[0]} — 고름 {self.uphill[1]} · " - f"읽음 {up.get('uphill_side')}/{up.get('uphill_side_source')}" - ) - for c in self.sent_irregular: - near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0] - if not any(abs(n - c) < 0.005 for n in near): - d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})") - # 소수 측점 횡단(PLAN 20장) — 그 자리 횡단 파일이 소수 2자리 이름으로 따로 있음 - name = cross_filename(c) - if not (root / "B06_Section" / "cross_sections" / name).is_file(): - d.append(f"관 측점 {c} — 횡단 파일 {name} 없음") - self._check_designs() - - def _check_designs(self) -> None: - """횡단 patch — DB 로 가는 값이 고른 값과 같고, 받을 행이 있어야 함(없으면 DB 가 버림).""" - rec = self.api.rec - known = [c for call in rec.of("known") for c in call["chainages"]] - designs = rec.of("designs") - made = [e[0] for call in designs if call.get("replace") for e in call["entries"]] - patched = { - round(e[0], 2): e[1] - for call in designs - if call.get("replace") is False - for e in call["entries"] - } - for patch in self.cross_patches: - ch = patch["chainage_m"] - body = {k: v for k, v in patch.items() if k != "chainage_m"} - got = patched.get(round(ch, 2)) - if got is None or any(got.get(k) != v for k, v in body.items()): - self.diffs.append(f"횡단 patch {ch} — 고름 {body} · DB 로 {got}") - elif not any(abs(float(k) - ch) < 0.01 for k in [*known, *made]): - self.diffs.append(f"횡단 patch {ch} — 받을 행이 없어 DB 가 버림") - - -def _snapshot(root: Path) -> dict[str, Any]: - lon = _read(root, LONGITUDINAL) - return { - "pipe_points.json": _read(root, PIPES_FILE)["points"], - "structures.json": _read(root, STRUCTURES_FILE)["structures"], - "계획고 수정": lon["profile_alignment"].get("edits"), - "종단 측점": [ - (s["chainage_m"], s.get("kind"), s.get("uphill_side")) for s in lon["stations"] - ], - } - - -def _resave_unchanged(flow: Flow) -> None: - """다시 열어 아무것도 안 바꾸고 [저장] — 저장본이 한 글자도 안 바뀌어야 함.""" - flow.put_pipes(flow.server_pipes()) - flow.put_alignment() - flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"]) - - -# 두 페이지 모두 저장 한 줄기(`A00_Common/design/design_save.ts` `DESIGN_SAVE_ORDER`)를 탄다 — -# 관 → 구조물 → 계획고 → 암거 옵션(구간값) → 오르막(상단측 확정) → 횡단(계획서 24-2a). -def _b05_save(flow: Flow) -> None: - """B05 [임시저장] — 오르막 차례에 관 측점(비정규 측점)도 함께 확정.""" - flow.put_pipes(flow.b05_pipe_payload()) - flow.put_structures(flow.screen_structures(reread=True)) - flow.put_alignment() - flow.flush_culvert() - flow.confirm(with_irregular=True) - flow.save_sections() - - -def _b06_save(flow: Flow) -> None: - """B06 [저장] — 오르막 차례는 상단측만 확정.""" - flow.put_pipes(flow.b06_pipe_payload()) - flow.put_structures(flow.screen_structures(reread=False)) - flow.put_alignment() - flow.flush_culvert() - flow.confirm(with_irregular=False) - flow.save_sections() - - -@pytest.mark.parametrize("save", [_b05_save, _b06_save], ids=["B05", "B06"]) -def test_저장_왕복_0_차이(api, save): - flow = Flow(api) - save(flow) - flow.check() - before = _snapshot(api.root) - _resave_unchanged(flow) - after = _snapshot(api.root) - flow.diffs += [f"다시 열어 그대로 저장 — {k} 가 바뀜" for k in before if before[k] != after[k]] - assert not flow.diffs, "\n".join(flow.diffs) diff --git a/resources/tester/test_b05_min_cover_guard.py b/resources/tester/test_b05_min_cover_guard.py index 786eefdb3..7d27e128d 100644 --- a/resources/tester/test_b05_min_cover_guard.py +++ b/resources/tester/test_b05_min_cover_guard.py @@ -97,56 +97,3 @@ def test_source_old_diameter_formula_removed(): assert "diameterMm ?? 1000" not in RENDER assert "findMinCoverViolations" in GUARD assert "minCoverTargets: () => minCoverTargets" in PANEL - - -# 구간 쉬프트 · R 입력 = `_Alignment_Edit.ts`(700줄 나눔 · 511-B B-3) — 두 파일을 한 글로 봄 -ALIGNMENT = "".join( - (PROJECT_ROOT / "B05_Profile" / "logic" / name).read_text(encoding="utf-8") - for name in ("B05_Profile_UI_Profile_Alignment.ts", "B05_Profile_UI_Profile_Alignment_Edit.ts") -) - - -def test_shift_moves_hinges_not_fixed_ends(): - """쉬프트 재정의(2026-08-23 사용자 개념 확정) 소스 잠금: - 고정점(BP·EP·배수 앵커)은 안 움직이고 안쪽 미틸트 측점 외곽 2개가 힌지로 - 승격(라운드 생성)된다. 힌지 부족이면 쉬프트 불가(버튼 숨김).""" - assert "export function shiftMovingPoints" in ALIGNMENT - assert "export function controlElevationAt" in ALIGNMENT - body = ALIGNMENT[ALIGNMENT.index("export function shiftSegment") :] - assert "shiftMovingPoints(current, segment)" in body - assert "if (!moving) return edits;" in body - assert "FEEDBACK_PASSES" in body # 라운드(중앙종거) 낀 자리도 1클릭 = 정확 delta. - EDIT = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Edit.ts").read_text( - encoding="utf-8" - ) - # 구간 쉬프트 버튼(⬆⬇)과 그 노출 조건(canShift)은 2026-09-02 지시로 삭제됐다. - # 기하 함수는 남겨 두기로 했으므로(PLAN.md) 위 존재 검사는 그대로 두고 배선만 잠근다. - assert "canShift" not in EDIT - assert "canShift:" not in RENDER - - -def _moving_points(stations, edited, seg_from, seg_to): - """TS shiftMovingPoints와 같은 규칙 — 끝점이 틸팅점이면 그 점, 아니면 안쪽 외곽 측점.""" - interior = [s for s in stations if seg_from + 1e-6 < s < seg_to - 1e-6] - left = seg_from if seg_from in edited else (interior[0] if interior else None) - right = seg_to if seg_to in edited else (interior[-1] if interior else None) - if left is None or right is None or right - left < 1e-6: - return None - return (left, right) - - -def test_moving_points_promote_interior_hinges(): - """앵커-앵커 구간: 안쪽 미틸트 측점 외곽 2개(첫·끝)가 힌지가 된다.""" - assert _moving_points([20, 40, 60, 80], set(), 0, 84.3) == (20, 80) - - -def test_moving_points_need_two_hinges(): - """안쪽 측점이 1개 이하면 힌지 둘을 못 만든다 — 쉬프트 불가.""" - assert _moving_points([20], set(), 0, 30) is None - assert _moving_points([], set(), 0, 30) is None - - -def test_moving_points_reuse_tilted_ends(): - """사용자가 틸팅한 끝점은 직접 움직인다 — 같은 구간 반복 쉬프트 경로.""" - assert _moving_points([40, 60], {20, 80}, 20, 80) == (20, 80) - assert _moving_points([40, 60], {80}, 20, 80) == (40, 80) diff --git a/resources/tester/test_b05_station_merge.mjs b/resources/tester/test_b05_station_merge.mjs deleted file mode 100644 index f87b184a2..000000000 --- a/resources/tester/test_b05_station_merge.mjs +++ /dev/null @@ -1,37 +0,0 @@ -/* 구조물 측점과 겹치는 규칙 측점 제거 — 2026-09-06. - TS 를 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다. */ -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; - -const out = mkdtempSync(join(tmpdir(), "aislo-station-")); -execFileSync( - process.execPath, - [ - "./config/node_modules/typescript/bin/tsc", - "B05_Profile/logic/B05_Profile_Util_Station.ts", - "--outDir", out, - "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", - ], - { stdio: "inherit" }, -); -const { dropStationsNear, STATION_MERGE_TOLERANCE_M } = await import( - pathToFileURL(join(out, "B05_Profile_Util_Station.js")).href -); - -const regular = [0, 20, 40, 60, 80].map((chainage_m) => ({ chainage_m, kind: "regular" })); -const structures = [{ chainage_m: 40.05 }, { chainage_m: 63.0 }]; -const kept = dropStationsNear(regular, structures); - -assert.deepEqual( - kept.map((s) => s.chainage_m), - [0, 20, 60, 80], - "0.1m 안에서 겹친 40m 규칙 측점만 빠져야 한다", -); -assert.equal(STATION_MERGE_TOLERANCE_M, 0.1); -// 구조물이 없으면 아무것도 지우지 않는다. -assert.equal(dropStationsNear(regular, []).length, regular.length); -console.log("OK — 구조물 측점과 겹친 규칙 측점만 제거"); diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index dc85dd348..65fd15a30 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -66,7 +66,6 @@ def test_종류를_바꾸면_통째로_새_값(out): def test_참조_모양_검사(out): assert out["isItem"] == [True, False, False, False] assert out["readItem"] == [A_KEY, None] - assert out["same"] == [True, False, True, False] # 이름만 바뀌면 같음 def test_키는_앞에_안_내세움_툴팁에만(out): diff --git a/resources/tester/test_b06_patch_station.py b/resources/tester/test_b06_patch_station.py index ad7a80cb0..d502698f4 100644 --- a/resources/tester/test_b06_patch_station.py +++ b/resources/tester/test_b06_patch_station.py @@ -2,7 +2,7 @@ 옮긴 관 · 구조물 측점은 B05 확정 전이면 DB 행이 없다. 저장 · 확정이 그 자리 수정을 받으면 `ensure_structure_stations`([측점 만들기]와 같은 길)를 불러 측점을 세운 뒤 행을 만든다. -DB · 파일 전체 왕복은 test_b05_b06_save_roundtrip [B06] 이 봄 — 여기서는 부르는 조건만. +여기서는 부르는 조건만. """ import asyncio