From 857b2d89d456b15b5695b0be026750f8ff90dab0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 18:55:25 +0900 Subject: [PATCH] =?UTF-8?q?fix(445=20B06=20=EC=A0=80=EC=9E=A5=20patch):=20?= =?UTF-8?q?=EB=B0=94=ED=83=95=20=3D=20=EC=BA=90=EC=8B=9C=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=B9=B8=20+=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=96=B9=EC=9D=8C=20=C2=B7=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=BC=20null=20=C2=B7=20=EC=98=AE=EA=B8=B4=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=EC=A7=91?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B2=94=EC=9C=84=20=C2=B7=20=EB=B2=84?= =?UTF-8?q?=EB=A6=B0=20=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H3 — 횡단 patch 바탕을 캐시 설계 사용자 칸(측점마다 통째)으로 · 세션 고침은 벽 · 단 · 쪽 열쇠마다 합침(mergeCrossPatches) - M2 — 벽 ↺ · 연동 풂 = 세션 null(안쪽 열쇠 지움) · 추가 벽 0 · 빈 전체 폭 0 을 세션에 남김 · 새 탭 − 가 저장분 단 수에서 출발 - H2 — 옮긴 관 · 구조물(정본 행 없음) = 지반 등 입력(CARRY_INPUT_KEYS)까지 실음 · 화면도 들고 감 · 서버 기본 설계가 그 입력으로 섬 - H5 — 노선 바꾼 [저장] 본문에 카드 선택 · 암선 · 암 경사 · 표준 횡단면 · 풍선 자리 - M1 — 집수정 내공 화면 범위 = 서버 1.0~2.0 - M5 — /save · /confirm 응답에 버린 patch 측점 · 기본 설계 실패 · 측점 맞춤 실패 · 화면 알림 - M7 — kindreset 을 측점 옮김 · 노선 저장에 따라 옮김 - 시험 test_445_save_patch · 기존 시험 꼴 맞춤(318 · 384 · 428 · 429 · fills_empty) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B06_Section/B06_Section_Api_Fetch.ts | 31 ++- B06_Section/B06_Section_Api_Types.ts | 4 + B06_Section/B06_Section_Repository_Bulk.py | 9 +- B06_Section/B06_Section_Router_Confirm.py | 71 ++++- B06_Section/B06_Section_Schema.py | 7 +- B06_Section/B06_Section_Section_Adjusts.ts | 12 +- B06_Section/B06_Section_Section_Store.ts | 90 ++++++- B06_Section/B06_Section_Station_Keys.ts | 5 + .../B06_Section_UI_Cross_Structure_Panel.ts | 2 +- B06_Section/B06_Section_UI_Page_Persist.ts | 6 +- .../B06_Section_UI_Page_Station_Controls.ts | 92 ++++--- B06_Section/logic/B06_Section_Station_Swap.ts | 32 ++- .../logic/B06_Section_UI_Page_Patches.ts | 52 +++- resources/tester/helper_429_save_path.cjs | 8 + resources/tester/helper_445_save_patch.cjs | 194 ++++++++++++++ resources/tester/test_318_cross_card.py | 4 +- resources/tester/test_384_s_area_keys.py | 4 +- resources/tester/test_428_pipe_move_mirror.py | 30 ++- resources/tester/test_429_save_path.py | 3 +- resources/tester/test_445_save_patch.py | 251 ++++++++++++++++++ .../test_b06_save_fills_empty_design.py | 2 +- 21 files changed, 799 insertions(+), 110 deletions(-) create mode 100644 resources/tester/helper_445_save_patch.cjs create mode 100644 resources/tester/test_445_save_patch.py diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index deb5804c1..89618f11e 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -194,15 +194,36 @@ export async function confirmSections( ); } -/** 서버 정본 셈이 실패했으면 경고 한 줄(388-2) — 저장은 남음 · 면적 · 관 길이는 옛 서버 값. */ +/** 서버 정본 셈이 실패했으면 경고 한 줄(388-2) — 저장은 남음 · 면적 · 관 길이는 옛 서버 값. + * 서버가 버린 고침(행 없는 측점 · 기본 설계 못 냄 · 측점 맞춤 실패)도 한 줄(445 M5). */ async function warnUnrecomputed( request: Promise, ): Promise { const result = await request; - if (result.recomputed === false) - void import("@ui/ui_template_elements").then(({ showToast }) => - showToast(L("B06_Recompute_Failed"), "warning", 8000), - ); + const dropped = [ + ...new Set([ + ...(result.dropped_patch_chainages ?? []), + ...(result.default_failed_chainages ?? []), + ]), + ]; + const where = dropped + .slice(0, 5) + .map((at) => `${at.toFixed(2)}m`) + .join(" · "); + const lost = [ + ...(dropped.length + ? [ + `횡단 고침 ${dropped.length}곳을 못 남겼습니다 — 측점 행이 없음: ${where}` + + (dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""), + ] + : []), + ...(result.stations_synced === false ? ["구조물 측점 맞춤 실패 — 옛 측점이 남았습니다."] : []), + ]; + if (result.recomputed === false || lost.length) + void import("@ui/ui_template_elements").then(({ showToast }) => { + if (result.recomputed === false) showToast(L("B06_Recompute_Failed"), "warning", 8000); + if (lost.length) showToast(lost.join(" "), "warning", 8000); + }); return result; } diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 1f7f323f4..b310af6c1 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -360,6 +360,10 @@ export interface SectionConfirmResponse { confirmed: boolean; /** 서버 정본 셈(면적 · 관 길이 · 유토곡선)이 됐나 — false 면 경고(388-2). */ recomputed?: boolean; + /** 버린 고침(445 M5) — 행 없는 측점 patch · 기본 설계를 못 낸 측점 · 구조물 측점 맞춤 실패. */ + dropped_patch_chainages?: number[]; + default_failed_chainages?: number[]; + stations_synced?: boolean; } /** 측점 표준횡단 설계 지정값 (버튼 상태). */ diff --git a/B06_Section/B06_Section_Repository_Bulk.py b/B06_Section/B06_Section_Repository_Bulk.py index 4cf3262e1..192633656 100644 --- a/B06_Section/B06_Section_Repository_Bulk.py +++ b/B06_Section/B06_Section_Repository_Bulk.py @@ -33,6 +33,7 @@ async def merge_cross_section_designs( entries: list[tuple[float, dict[str, Any]]], replace: bool, project_id: UUID | None = None, + missed: list[float] | None = None, ) -> int: """측점 여러 곳의 `data.design` 을 한 번에 쓴다. 실제로 바뀐 행 수를 돌려준다. @@ -43,7 +44,7 @@ async def merge_cross_section_designs( 파일만 쓰고 DB 행을 안 만들기 때문이다(한 행짜리 함수와 같은 규칙). """ return await merge_cross_section_design_batches( - connection, route_id=route_id, batches=[(entries, replace, project_id)] + connection, route_id=route_id, batches=[(entries, replace, project_id)], missed=missed ) @@ -52,11 +53,13 @@ async def merge_cross_section_design_batches( *, route_id: int, batches: list[tuple[list[tuple[float, dict[str, Any]]], bool, UUID | None]], + missed: list[float] | None = None, ) -> int: """여러 벌(`(entries, replace, project_id)`)을 차례로 얹되 읽기 한 번 · 쓰기 한 번(342-2). 앞 벌이 만든 새 행도 뒤 벌이 찾는다(따로 부르던 때와 같은 결과). 값이 그대로인 행은 쓰지 - 않는다 — 같은 입력의 [저장]·[확정]이 행을 다시 쓰지 않게. + 않는다 — 같은 입력의 [저장]·[확정]이 행을 다시 쓰지 않게. `missed` 를 주면 행이 없어 못 얹은 + 측점(값이 있는 줄만)을 담음 — 버린 수 알림(445 M5). """ if not any(entries for entries, _, _ in batches): return 0 @@ -98,6 +101,8 @@ async def merge_cross_section_design_batches( row_id = find(chainage_m) if row_id is None: if project_id is None or not (payload or replace): + if missed is not None and payload: + missed.append(chainage_m) continue row_id = -(len(fresh) + 1) fresh[row_id] = (str(project_id), chainage_m) diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 7a446cd82..7f78d29ab 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -38,7 +38,7 @@ from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs # 원본은 `B06_Section_Router_Design` 이다 — `B06_Section_Router` 를 거쳐 들여오던 것을 # 곧바로 잇는다(2026-09-06). 그 재수출이 없어지면서 서버가 뜨지 못했다. -from B06_Section.B06_Section_Router_Design import STRUCTURE_ADJUST_KEYS +from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS, STRUCTURE_ADJUST_KEYS from B06_Section.B06_Section_Router_Design import ( compute_default_designs as _compute_default_designs, ) @@ -91,20 +91,47 @@ def _rowless_station_chainages( return result -async def _stand_patch_stations(project_id: UUID) -> None: +async def _stand_patch_stations(project_id: UUID) -> bool: """저장 · 확정 앞에 구조물 측점을 지금 관 · 구조물 목록으로 맞춘다(B05·B06 한 페이지처럼). 옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없고, 옛 자리 측점은 남아 있다 — 행 없는 측점의 수정이 버려지고 옛 측점이 카드 · 수량에 남음. [측점 만들기]와 같은 조건으로 세우고 낡은 것을 지우면(맞출 것이 없으면 그냥 돌아옴 · 272) 아래 - `_rowless_station_chainages` 가 행을 만들고 수정이 그 행에 얹힌다. + `_rowless_station_chainages` 가 행을 만들고 수정이 그 행에 얹힌다. 맞춤이 실패하면 False + (응답에 실어 알림 · 445 M5). """ from B06_Section.B06_Section_Router_Stations import ensure_structure_stations try: await ensure_structure_stations(project_id) + return True except Exception: # 맞춤이 안 돼도 저장은 진행 — 옛 측점이 남을 뿐(다음 저장에서 다시) logger.exception("B06 저장: 구조물 측점 맞춤 실패(저장은 진행): project_id=%s", project_id) + return False + + +def _carried_inputs( + request: SectionConfirmRequest | None, rowless: list[float] +) -> dict[float, dict[str, Any]]: + """행 없는 측점(옮긴 관 · 구조물 · 새 측점)에 온 patch 의 지반 등 입력 — 기본 설계를 그 입력으로 + 세움(445 H2 · 노선 [확인] 이월과 같은 `carried` · 측구 방향 결과 칸 = 화면 다시 셈).""" + carried: dict[float, dict[str, Any]] = {} + for item in (request.cross_patches or []) if request else []: + at = next((c for c in rowless if abs(c - item.chainage_m) < 0.01), None) + if at is None: + continue + dumped = item.model_dump() + picked = {key: dumped[key] for key in CARRY_INPUT_KEYS if dumped.get(key) is not None} + if picked: + carried.setdefault(round(at, 3), {}).update(picked) + return carried + + +def _default_failed( + rowless: list[float], defaults: list[tuple[float, dict[str, Any]]] +) -> list[float]: + """기본 설계를 못 낸 측점 — 계산 불가 · 횡단 파일 없음(445 M5).""" + return [c for c in rowless if all(abs(c - at) >= 0.01 for at, _ in defaults)] async def _apply_section_edits( @@ -113,8 +140,12 @@ async def _apply_section_edits( request: SectionConfirmRequest | None, default_designs: list[tuple[float, dict[str, Any]]], project_id: UUID | None = None, -) -> None: - """임시 저장과 확정이 **함께 쓰는** 저장 본체. 트랜잭션은 호출한 쪽이 연다.""" +) -> list[float]: + """임시 저장과 확정이 **함께 쓰는** 저장 본체. 트랜잭션은 호출한 쪽이 연다. + + 돌려주는 값 = 행이 없어 못 얹은 측점 patch 의 누가거리(445 M5 · 화면이 알림). + """ + missed: list[float] = [] # 행마다 쓰면 원격 DB 왕복이 행 수만큼 난다(측정: 22행 670ms) — 한 문장으로 묶는다. await merge_cross_section_designs( connection, @@ -158,8 +189,11 @@ async def _apply_section_edits( patches.append((patch_item.chainage_m, patch)) # 측점 patch 도 한 문장으로 — 전 측점을 보내는 저장에서 왕복이 측점 수만큼 났다. await merge_cross_section_designs( - connection, route_id=route_id, entries=resets + patches, replace=False + connection, route_id=route_id, entries=resets + patches, replace=False, missed=missed ) + if missed: + logger.warning("B06 저장: 행 없는 측점 patch 버림 route_id=%s %s", route_id, missed) + return missed async def _recompute_stored_designs(project_id: UUID, route_id: int) -> bool: @@ -267,7 +301,7 @@ async def save_sections( return stale stored_path = await get_project_storage_relative_path(connection, project_id) # 측점부터 맞춤 — 아래 행 조회가 맞춘(옮기고 지운) 뒤 행을 봄(272) - await _stand_patch_stations(project_id) + synced = await _stand_patch_stations(project_id) async with pool.acquire() as connection: known = await get_cross_section_chainages(connection, route_id) # 행은 있는데 **설계가 빈** 측점도 채운다(2026-09-09). 「빈 설계」란 `design` 이 @@ -296,12 +330,13 @@ async def save_sections( str(existing["longitudinal_file_path"]), rowless, request.standard_cross_section if request else None, + _carried_inputs(request, rowless), ) async with pool.acquire() as connection: await connection.begin() try: - await _apply_section_edits( + missed = await _apply_section_edits( connection, route_id, request, default_designs, project_id ) await connection.commit() @@ -312,7 +347,13 @@ async def save_sections( recomputed = await _recompute_stored_designs(project_id, route_id) await _prebuild_corridor(project_id, route_id, project_root) return SectionConfirmResponse( - project_id=str(project_id), route_id=route_id, confirmed=False, recomputed=recomputed + project_id=str(project_id), + route_id=route_id, + confirmed=False, + recomputed=recomputed, + dropped_patch_chainages=missed or [], + default_failed_chainages=_default_failed(rowless, default_designs), + stations_synced=synced, ) except Exception: logger.exception( @@ -354,7 +395,7 @@ async def confirm_sections( if stale: return stale stored_path = await get_project_storage_relative_path(connection, project_id) - await _stand_patch_stations( + synced = await _stand_patch_stations( project_id ) # 측점부터 맞춤 — 아래 행 조회가 맞춘 뒤 행을 봄(272) async with pool.acquire() as connection: @@ -384,6 +425,7 @@ async def confirm_sections( str(existing["longitudinal_file_path"]), missing, request.standard_cross_section if request else None, + _carried_inputs(request, missing), ) marks.append(("기본설계 계산(미지정 측점)", time.perf_counter())) @@ -391,7 +433,7 @@ async def confirm_sections( async with pool.acquire() as connection: await connection.begin() try: - await _apply_section_edits( + missed = await _apply_section_edits( connection, route_id, request, default_designs, project_id ) await confirm_sections_for_route(connection, route_id) @@ -445,7 +487,12 @@ async def confirm_sections( marks.append(("3D 길 모형 서버 짓기", time.perf_counter())) _log_steps("B06 확정", marks) return SectionConfirmResponse( - project_id=str(project_id), route_id=route_id, recomputed=recomputed + project_id=str(project_id), + route_id=route_id, + recomputed=recomputed, + dropped_patch_chainages=missed or [], + default_failed_chainages=_default_failed(missing, default_designs), + stations_synced=synced, ) except Exception: logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 231024484..20d81a71b 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -195,7 +195,8 @@ class CrossSectionPatch(BaseModel): # 암 경계선 오프셋(m, 지면선 기준 하향 음수 — 계획선 아님). 암 지반 측점만 의미 있다. rock_boundary_offset_m: float | None = None # 측점별 표시 반폭(m) — 카드 개별 조절값. 전역 반폭과 다를 때만 실린다(2026-08-06). - display_half_width_m: float | None = Field(default=None, gt=0) + # 0 = 되돌림(빈 전체 폭 · 정본의 옛 개별 폭을 지움 · 445 M2) — 읽는 쪽은 0 이하를 「없음」 + display_half_width_m: float | None = Field(default=None, ge=0) # 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). # **0 은 「표준값을 씀」**(되돌리기)이라 gt=0 이 아니라 ge=0 이다 — 0 이 와야 # 정본에 남은 옛 사용자 값이 지워진다. @@ -284,6 +285,10 @@ class SectionConfirmResponse(BaseModel): confirmed: bool = True # 서버 정본 셈(면적 · 관 길이 · 유토곡선)이 됐나 — False 면 화면이 경고 한 줄(388-2) recomputed: bool = True + # 버린 고침(445 M5) — 행이 없어 못 얹은 측점 patch · 기본 설계를 못 낸 측점 · 측점 맞춤 실패 + dropped_patch_chainages: list[float] = Field(default_factory=list) + default_failed_chainages: list[float] = Field(default_factory=list) + stations_synced: bool = True class SectionOptionDefaults(BaseModel): diff --git a/B06_Section/B06_Section_Section_Adjusts.ts b/B06_Section/B06_Section_Section_Adjusts.ts index 0698802a4..146aeb3dd 100644 --- a/B06_Section/B06_Section_Section_Adjusts.ts +++ b/B06_Section/B06_Section_Section_Adjusts.ts @@ -65,12 +65,22 @@ export function withDraftAdjusts( for (const [role, value] of own(revet)) { const adjust = wallAdjust(value); if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust }; + else if (value === null && design.revet_adjust?.[role]) { + // null = 되돌림 — 저장분 그 벽을 뺌(445 M2) + const { [role]: _gone, ...rest } = design.revet_adjust; + design.revet_adjust = rest; + } } if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string)) design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"]; if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] }; + // 세션에 있는 쪽만 — 없는 쪽은 저장분 그대로(445 H3) + const saved = design.extra_wall_counts; if (at in extra || `b${at}` in extra) - design.extra_wall_counts = { outlet: count(extra[at]), basin: count(extra[`b${at}`]) }; + design.extra_wall_counts = { + outlet: at in extra ? count(extra[at]) : (saved?.outlet ?? 0), + basin: `b${at}` in extra ? count(extra[`b${at}`]) : (saved?.basin ?? 0), + }; for (const [wall, value] of own(spans)) { const span = value as { lengthM: number; beforeM: number; afterM: number } | null; if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue; diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 6fa61ecd4..ac87151c4 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -29,8 +29,12 @@ import { } from "./B06_Section_Section_Drafts"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import { withDraftAdjusts, withPipeOptions } from "./B06_Section_Section_Adjusts"; -import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { forgetCanon, keepCanon, sessionDetail } from "./B06_Section_Section_Canon"; +import type { + CrossSectionPatch, + SectionDetailResponse, + StandardCrossSection, +} from "./B06_Section_Api_Fetch"; +import { forgetCanon, keepCanon, savedDesigns, sessionDetail } from "./B06_Section_Section_Canon"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options"; @@ -44,7 +48,16 @@ import { type Keyed, type KeyedState, } from "./B06_Section_Station_Keys"; -import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap"; +import { + ROW_CARRY_KEYS, + sameStationList, + swapCrossSections, +} from "./logic/B06_Section_Station_Swap"; +import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches"; +import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; +import { readCutSlopeSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel"; +import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { emitSwap, type DetailListener } from "./B06_Section_Swap_Bus"; export { onAnySwap } from "./B06_Section_Swap_Bus"; @@ -319,16 +332,20 @@ export function peekSectionDetail( * 최종본이다. B05 [임시저장]도 이 세트를 함께 보내야 B06에서 만진 구조물 조정이 * 영구저장소에 남는다 — 안 보내면 캐시만 비워져 편집이 사라진다. * - * 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)만 싣는다. + * 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)를 측점마다 **통째로** 싣는다 — [저장] patch 의 바탕 + * (445 H3 · 세션 고침은 그 위에 `mergeCrossPatches`). `savedAt`(서버 행 누가거리)을 주면 그 자리 행이 없는 + * 측점(옮긴 관 · 구조물 · 새 측점 = 서버가 기본 설계로 새로 세움)은 지반 등 입력(`ROW_CARRY_KEYS`)도 실음(445 H2). */ export function crossPatchesFromCache( detail: SectionDetailResponse, kindChanged: readonly number[] = [], + savedAt?: readonly number[], ): CrossSectionPatch[] { const patches: CrossSectionPatch[] = []; // 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값은 서버가 비움 · 캐시에 남은 옛 값을 다시 안 실음 const structureKeys: readonly string[] = STRUCTURE_ADJUST_KEYS; const reset = (at: number) => kindChanged.some((c) => Math.abs(c - at) < PIPE_MATCH_M); + const rowless = (at: number) => !!savedAt && savedAt.every((c) => Math.abs(c - at) >= 0.01); for (const at of kindChanged) patches.push({ chainage_m: at, structure_reset: true }); for (const section of detail.cross_sections) { const design = section.design; @@ -341,7 +358,10 @@ export function crossPatchesFromCache( // 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`). const source = design as unknown as Record; const target = patch as unknown as Record; - for (const key of USER_TOUCHED_KEYS) { + const keys = rowless(section.chainage_m) + ? [...USER_TOUCHED_KEYS, ...ROW_CARRY_KEYS] + : USER_TOUCHED_KEYS; + for (const key of keys) { const value = source[key]; if (value === undefined || value === null || (skip && structureKeys.includes(key))) continue; target[key] = value; @@ -353,14 +373,62 @@ export function crossPatchesFromCache( } /** - * 캐시에 얹힌 사용자 수정분을 영구저장소에 남긴다(노선 저장 뒤 · 임시저장용). 수정분이 없어도 - * 한 번 보냄 — 서버가 지금 종단으로 횡단을 다시 셈(429 ④ · 노선 뒤에 나간 계획고 초안). - * 저장 실패는 호출자가 처리한다. + * [저장] · [확정] 횡단 patch(445) — 바탕 = 캐시 설계의 사용자 칸(측점마다 통째 · 서버 행이 없는 측점은 지반 등 + * 입력까지) 위에 세션 고침(`overlay` · 벽 · 단 dict 는 열쇠마다 합침 · 안쪽 null = 되돌림). 캐시가 없으면 세션 고침만. + */ +export function cachedCrossPatches( + projectId: string, + routeId: number, + overlay: readonly CrossSectionPatch[] = [], +): CrossSectionPatch[] { + const detail = cache.get(keyOf(projectId, routeId)); + const savedAt = savedDesigns(projectId, routeId)?.map((row) => row.chainage_m); + const base = detail + ? crossPatchesFromCache(detail, kindChangedChainages(projectId), savedAt) + : []; + return mergeCrossPatches(base, overlay); +} + +/** 노선 저장 뒤 세션에만 있는 고침(카드 선택 · 암선 · 암 경사) — 횡단 보기 [저장] 본문과 같은 칸(445 H5). */ +function sessionOverlay(projectId: string, routeId: number): CrossSectionPatch[] { + const out: CrossSectionPatch[] = []; + crossDesignChoices(projectId, routeId).forEach((choice, chainage) => { + const patch: CrossSectionPatch = { + chainage_m: chainage, + ground_type: choice.ground_type, + section_mode: choice.section_mode, + }; + if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side; + if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type; + if (choice.paved !== undefined) patch.paved = choice.paved; + if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope; + if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice; + out.push(patch); + }); + const rock = readRockBoundarySession(projectId, routeId) ?? {}; + for (const [chainage, offset] of Object.entries(rock)) + if (typeof offset === "number") + out.push({ chainage_m: Number(chainage), rock_boundary_offset_m: offset }); + for (const [chainage, ratio] of Object.entries(readCutSlopeSession(projectId, routeId))) + out.push({ chainage_m: Number(chainage), cut_slope_ratio_user: ratio }); + return out.filter((patch) => Number.isFinite(patch.chainage_m)); +} + +/** + * 노선 저장 뒤 고침을 영구저장소에 남긴다 — 횡단 보기 [저장]과 같은 본문(캐시 사용자 칸 · 카드 선택 · 암선 · + * 암 경사 · 표준 횡단면 · 풍선 자리 · 445 H5). 수정분이 없어도 한 번 보냄 — 서버가 지금 종단으로 횡단을 다시 셈 + * (429 ④ · 노선 뒤에 나간 계획고 초안). 저장 실패는 호출자가 처리한다. */ export async function saveCachedCrossPatches(projectId: string, routeId: number): Promise { - const detail = cache.get(keyOf(projectId, routeId)); - const patches = detail ? crossPatchesFromCache(detail, kindChangedChainages(projectId)) : []; - await saveSections(projectId, routeId, undefined, patches.length ? patches : undefined); + const patches = cachedCrossPatches(projectId, routeId, sessionOverlay(projectId, routeId)); + const standard = readStandardCrossSession(projectId) as StandardCrossSection | null; + await saveSections( + projectId, + routeId, + standard ?? undefined, + patches.length ? patches : undefined, + { balloon_offsets: balloonOffsetsPayload() }, + ); clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4) return patches.length; } diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index e63b1f823..25c5b78a9 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -478,6 +478,9 @@ export function carryChainageStates( : rekey(state, raw); writeState(name, next, projectId, toRouteId); } + // 관 종류 바꿈 자리(318-4 · 프로젝트 칸) — 구조물 조절과 같은 규칙으로 옮김(445 M7) + const resets = readState>("kindreset", projectId); + if (resets) writeState("kindreset", rekey("revetx", resets), projectId); return outside.size; } @@ -510,6 +513,8 @@ function moveIdleStates(moves: ReadonlyMap, owned: ReadonlySet>("kindreset", projectId); // 445 M7 + if (resets) writeState("kindreset", move(resets), projectId); } afterAnySwap((_detail, moves, owned) => moveIdleStates(moves, owned)); diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 318a9b786..439d042e5 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -311,7 +311,7 @@ export function buildStructurePanel( parts.row.classList.add("b06-structure-panel__struct--basin-size"); const readout = document.createElement("span"); readout.className = "b06-structure-panel__hval"; - // 한계 없음 — 그림이 안 서는 0 이하만 한 눈금에서 멈춤(54-51). + // 범위 = 서버 1.0~2.0 — 조정창(`updateAdjust`)이 자름(445 M1 · 넘으면 422 로 저장이 멈춤). const step = (delta: number) => basinPatch(() => ({ [field]: Math.max( diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 0a02dbbc3..3d6e0b82b 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -505,11 +505,15 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave { const routeId = ctx.routeId(); if (routeId === null || !ctx.projectId) return; const edits = collectSectionEdits(ctx); + // 바탕 = 캐시 설계의 사용자 칸(측점마다 통째 · 옮긴 측점은 지반 등 입력까지) + 세션 고침(445 H2 · H3 · M2) + // 가게는 늦게 불러옴(가게 → 다시 셈 → 이 파일 순환 없음) + const { cachedCrossPatches } = await import("./B06_Section_Section_Store"); + const patches = cachedCrossPatches(ctx.projectId, routeId, edits.crossPatches); await (confirm ? confirmSections : saveSections)( ctx.projectId, routeId, ctx.standardValues(), - edits.crossPatches.length ? edits.crossPatches : undefined, + patches.length ? patches : undefined, edits.massHaul, ); clearState("kindreset", ctx.projectId); // 비울 자리를 보냈음(318-4) diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 7a6ebe3c7..9654f5b7b 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -89,10 +89,10 @@ export interface StationControls { widths: Map; inletStructures: Map; basinAdjustments: Map; - /** 확정 payload용 — 측점별 기슭막이 4축 조작값(역할 → 값). */ - revetAdjustsByChainage: () => Map>; - /** 확정 payload용 — 측점별 다단 단 수. */ - extraCountsByChainage: () => Map; + /** 확정 payload용 — 측점별 기슭막이 4축 조작값(역할 → 값 · null = 되돌림). */ + revetAdjustsByChainage: () => Map>; + /** 확정 payload용 — 측점별 다단 단 수(세션에 있는 쪽만). */ + extraCountsByChainage: () => Map>; /** 확정 payload용 — 측점별 연동 해제·종단경사 반영(2026-08-24). */ linkFlagsByChainage: () => Map; /** 다단 단별 구간값(2026-08-29) — 확정·임시저장 payload용. */ @@ -127,10 +127,11 @@ export function createStationControls(deps: StationControlDeps): StationControls * 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때 * cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다. * 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */ + // 0 = 되돌림(빈 전체 폭 · 저장분 개별 폭을 지움 · 445 M2) const widthSession = createSessionMap( () => deps.sessionKey("crossw"), (value) => - typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined, + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined, ); const stationWidths = widthSession.values; const loadStationWidths = widthSession.load; @@ -142,7 +143,7 @@ export function createStationControls(deps: StationControlDeps): StationControls const stationWidthControl: StationWidthControl = { widthFor: (section) => { const session = stationWidths.get(widthKey(section.chainage_m)); - if (session !== undefined) return session; + if (session !== undefined) return session > 0 ? session : undefined; const stored = section.design?.display_half_width_m; return typeof stored === "number" && stored > 0 ? stored : undefined; }, @@ -169,9 +170,11 @@ export function createStationControls(deps: StationControlDeps): StationControls * 값은 세션에만 담는다 — 자동 자리가 지형·계획고를 따라 다시 풀리므로, 손으로 * 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`. * 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */ - const revetSession = createSessionMap( + // null = 되돌림(저장분 그 벽을 지움 · 다시 열어도 안 살아남 · 445 M2) + const revetSession = createSessionMap( () => deps.sessionKey("revetx"), (value) => { + if (value === null) return null; // 구 형식(숫자 = x 이동량)도 읽어 준다. if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value }; if (!value || typeof value !== "object") return undefined; @@ -196,11 +199,12 @@ export function createStationControls(deps: StationControlDeps): StationControls /** 손으로 미는 범위 한계(m) — 조정 단위가 관 길이 1m이라 ±10m(=관 10m분)까지 둔다. */ const round1 = (value: number): number => Math.round(value * 10) / 10; const clampMove = (value: number): number => Math.min(Math.max(round1(value), -10), 10); + /** 집수정 내공 = 서버 범위(`BasinAdjustPatch` 1.0~2.0 · 넘으면 422 로 저장이 멈춤 · 445 M1). */ + const clampBasinInner = (value: number): number => Math.min(Math.max(value, 1), 2); const isDefaultAdjust = (value: WallAdjust): boolean => Math.abs(value.x) < 1e-9 && value.d == null && value.h == null && value.m == null; const storeAdjust = (key: string, value: WallAdjust): void => { - if (isDefaultAdjust(value)) revetShifts.delete(key); - else revetShifts.set(key, value); + revetShifts.set(key, isDefaultAdjust(value) ? null : value); persistRevetShifts(); // 같은 값을 캐시(design.revet_adjust)에도 실어 3D가 바로 따라오게 한다. const [chainage, role] = key.split(":"); @@ -314,10 +318,14 @@ export function createStationControls(deps: StationControlDeps): StationControls * auto(규칙)/revet(기슭막이+배관)/I/L/U(집수정 형식). 세션에만 담는다. */ const basinSession = createSessionMap( () => deps.sessionKey("basinadjust"), - (value) => - value && typeof value === "object" - ? { ...DEFAULT_BASIN_ADJUST, ...(value as Partial) } - : undefined, + (value) => { + if (!value || typeof value !== "object") return undefined; + const adjust = { ...DEFAULT_BASIN_ADJUST, ...(value as Partial) }; + // 옛 세션의 범위 밖 내공 — 서버가 422 로 막아 저장이 멈춤(445 M1) + adjust.innerWidthM = clampBasinInner(adjust.innerWidthM); + adjust.innerHeightM = clampBasinInner(adjust.innerHeightM); + return adjust; + }, ); const structSession = createSessionMap( () => deps.sessionKey("inletstruct"), @@ -335,9 +343,10 @@ export function createStationControls(deps: StationControlDeps): StationControls /* ── 유출측 추가 기슭막이 개수(2026-08-22 사용자 — 성토부 5m 이상 계단식) ── * 측점별 개수만 세션에 담는다. 각 벽의 이동량은 revetShifts에 `extra{n}` 키로. */ + // 0 = 되돌림(저장분 단 수를 지움 · 445 M2) const extraSession = createSessionMap( () => deps.sessionKey("extrawall"), - (value) => (Number.isInteger(value) && (value as number) > 0 ? (value as number) : undefined), + (value) => (Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined), ); const extraCounts = extraSession.values; const loadExtraCounts = extraSession.load; @@ -349,6 +358,13 @@ export function createStationControls(deps: StationControlDeps): StationControls const extraKey = (chainageM: number, side: "outlet" | "basin"): string => side === "basin" ? `b${chainageM.toFixed(2)}` : chainageM.toFixed(2); const extraPrefix = (side: "outlet" | "basin"): string => (side === "basin" ? "bextra" : "extra"); + /** 두 쪽 단 수 — 세션 → 저장분 · `side` 는 `count`(캐시 patch · 다른 쪽을 0 으로 덮지 않음 · 445 H3). */ + const countsWith = (chainageM: number, side: "outlet" | "basin", count: number) => { + const counts = { outlet: 0, basin: 0, ...sectionAt(chainageM)?.design?.extra_wall_counts }; + for (const other of ["outlet", "basin"] as const) + counts[other] = extraCounts.get(extraKey(chainageM, other)) ?? counts[other]; + return { ...counts, [side]: count }; + }; const extraWallControl: ExtraWallControl = { countFor: (section, side = "outlet") => @@ -363,11 +379,12 @@ export function createStationControls(deps: StationControlDeps): StationControls consumeEqualize: (section) => pendingEqualize.delete(section.chainage_m.toFixed(2)), setCount: (chainageM, requested, side = "outlet") => { const key = extraKey(chainageM, side); - const previous = extraCounts.get(key) ?? 0; + // 세션 → 저장분(새 탭에서 저장된 단을 줄여도 먹음 · 445 M2) + const previous = + extraCounts.get(key) ?? sectionAt(chainageM)?.design?.extra_wall_counts?.[side] ?? 0; const count = Math.max(0, Math.min(9, Math.round(requested))); if (count === previous) return; - if (count === 0) extraCounts.delete(key); - else extraCounts.set(key, count); + extraCounts.set(key, count); // 0 도 남김 — 저장분 단 수를 지움(445 M2) // 줄어든 단의 이동량·구간값은 지운다 — 다시 늘리면 기본값에서 시작한다. for (let i = count; i < previous; i += 1) { revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey)); @@ -376,12 +393,7 @@ export function createStationControls(deps: StationControlDeps): StationControls persistRevetShifts(); persistExtraSpans(); persistExtraCounts(); - patchCachedDesign(chainageM, { - extra_wall_counts: { - outlet: extraCounts.get(extraKey(chainageM, "outlet")) ?? 0, - basin: extraCounts.get(extraKey(chainageM, "basin")) ?? 0, - }, - }); + patchCachedDesign(chainageM, { extra_wall_counts: countsWith(chainageM, side, count) }); refreshWithLinked(chainageM); deps.onExtraCount?.(); }, @@ -401,10 +413,7 @@ export function createStationControls(deps: StationControlDeps): StationControls persistExtraCounts(); // 캐시 patch 도 세운 만큼으로 — 세션 키를 지우면 `countFor` 가 옛 patch 수로 돌아감(54-1) patchCachedDesign(chainageM, { - extra_wall_counts: { - outlet: extraCounts.get(extraKey(chainageM, "outlet")) ?? 0, - basin: extraCounts.get(extraKey(chainageM, "basin")) ?? 0, - }, + extra_wall_counts: countsWith(chainageM, side, Math.max(built, 0)), }); deps.onExtraCount?.(); }, @@ -431,8 +440,8 @@ export function createStationControls(deps: StationControlDeps): StationControls const current = basinAdjustments.get(key) ?? { ...DEFAULT_BASIN_ADJUST }; const next = { ...current, ...patch }; basinAdjustments.set(key, { - innerWidthM: Math.max(0.1, round1(next.innerWidthM)), - innerHeightM: Math.max(0.1, round1(next.innerHeightM)), + innerWidthM: clampBasinInner(round1(next.innerWidthM)), + innerHeightM: clampBasinInner(round1(next.innerHeightM)), lateralM: Math.max(0, clampMove(next.lateralM)), slopeM: Math.max(0, clampMove(next.slopeM)), }); @@ -515,12 +524,12 @@ export function createStationControls(deps: StationControlDeps): StationControls */ const dropLinkedAdjusts = (chainageM: number): void => { const prefix = `${chainageM.toFixed(2)}:`; - for (const key of [...revetShifts.keys()]) { - if (!key.startsWith(prefix) || key.slice(prefix.length) === "own") continue; - revetShifts.delete(key); - } - persistRevetShifts(); const design = sectionAt(chainageM)?.design; + const roles = [...revetShifts.keys()].filter((key) => key.startsWith(prefix)); + for (const role of Object.keys(design?.revet_adjust ?? {})) roles.push(`${prefix}${role}`); + // 저장분 굳힌 값도 되돌림 표시(null · 445 M2) + for (const key of roles) if (key.slice(prefix.length) !== "own") revetShifts.set(key, null); + persistRevetShifts(); if (!design?.revet_adjust) return; const own = design.revet_adjust.own; design.revet_adjust = own ? { own } : {}; @@ -608,7 +617,7 @@ export function createStationControls(deps: StationControlDeps): StationControls basinAdjustments, // 확정·임시저장 payload용 — 세션 키(누가거리:역할)를 측점별로 되접는다. revetAdjustsByChainage: () => { - const result = new Map>(); + const result = new Map>(); revetShifts.forEach((adjust, key) => { const [chainage, role] = key.split(":"); const value = Number(chainage); @@ -632,12 +641,13 @@ export function createStationControls(deps: StationControlDeps): StationControls return result; }, extraCountsByChainage: () => { - const result = new Map(); + // 세션에 있는 쪽만 — 없는 쪽은 바탕(캐시 설계) 값 그대로(445 H3 · `mergeCrossPatches`) + const result = new Map>(); extraCounts.forEach((count, key) => { const basin = key.startsWith("b"); const value = Number(basin ? key.slice(1) : key); if (!Number.isFinite(value)) return; - const bucket = result.get(value) ?? { outlet: 0, basin: 0 }; + const bucket = result.get(value) ?? {}; bucket[basin ? "basin" : "outlet"] = count; result.set(value, bucket); }); @@ -670,11 +680,9 @@ export function createStationControls(deps: StationControlDeps): StationControls }, applyGlobalWidth: (requested, chainages) => { stationWidths.clear(); - if (requested !== undefined) { - for (const chainageM of chainages) { - stationWidths.set(widthKey(chainageM), clampStationWidth(requested)); - } - } + // 빈 전체 폭 = 0(되돌림 — 저장분 개별 폭도 지움 · 445 M2) + const width = requested === undefined ? 0 : clampStationWidth(requested); + for (const chainageM of chainages) stationWidths.set(widthKey(chainageM), width); persistStationWidths(); }, }; diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts index 6adaba697..8dd6b34e8 100644 --- a/B06_Section/logic/B06_Section_Station_Swap.ts +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -6,8 +6,9 @@ * 자리(누가거리 · 좌표 · 방위 · 확폭)와 지반 표본(채워 왔으면)만 새것. * · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움). * · 상단측 = 새 지반 표본으로 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428). - * · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자가 만진 칸만 — 서버가 그 행을 지우고 기본 설계에 - * [저장] 수정분을 얹는 것과 같음(`sync_cross_section_rows` · `compute_default_designs` · 428). + * · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가 + * 그 행을 지우고 기본 설계에 [저장] patch 를 얹음(`sync_cross_section_rows` · `compute_default_designs` · 428) · + * patch 가 그 칸을 모두 실음(`crossPatchesFromCache` · 445 H2). * · 빠진 측점 = 목록에서 뺌(세션 고침은 열쇠로 남음 · 노선이 바뀐 때만 「노선 밖 고침」 · 부르는 쪽). * · 옛 상세는 그대로 둠 — 새 객체를 돌려줌(쥔 쪽이 갈아끼움). * @@ -66,21 +67,30 @@ const freshDesign = (uphill: CrossSection["uphill_side"]) => /** 서버가 행을 지우고 새로 세우는 옮김 폭(m) — `sync_cross_section_rows` 의 0.5 m 짝. */ const FACILITY_RESEED_M = 0.5; -/** 다시 셈 입력 중 기본 설계가 정하는 칸 — 옮긴 시설 측점은 버림(세션 고침은 열쇠째 옮겨 다시 얹힘). */ -const AUTO_INPUT_KEYS = [ - "ditch_side", - "rock_boundary_offset_m", - "two_stage_slope", +/** 다시 셈 입력 중 새 지형이 정하는 칸 — 옮긴 시설 측점은 버림(단면 유형은 기본 설계 · 서버 `CARRY_INPUT_KEYS` 밖). */ +const AUTO_INPUT_KEYS = ["ditch_side", "ditch_enabled"]; +/** + * 옮긴 측점이 들고 가는 사용자 입력(445 H2) — 서버 `CARRY_INPUT_KEYS` 짝(노선 [확인] 이월과 같은 칸). + * 서버에 그 자리 행이 없으면(옮김 · 새 측점) [저장] patch 가 캐시 설계의 이 칸을 실음. + */ +export const ROW_CARRY_KEYS = [ + "ground_type", + "ditch_type", "ditch_choice", - "ditch_enabled", -]; + "two_stage_slope", + "rock_boundary_offset_m", + "cut_slope_ratio_user", + "berm", +] as const; -/** 옮긴 시설 측점 설계 — 기본 설계 위에 앞 설계의 나머지(사용자가 만진 칸 · 결과 칸은 다시 셈이 갈음). */ +/** 옮긴 시설 측점 설계 — 기본 설계 위에 앞 설계의 나머지(사용자 칸 · 지반 등 입력) · 결과 칸은 다시 셈이 갈음. */ function reseeded( prior: CrossSection["design"], uphill: CrossSection["uphill_side"], ): NonNullable { - const design = { ...prior, ...freshDesign(uphill) } as Record; + const before = (prior ?? {}) as Record; + const design = { ...before, ...freshDesign(uphill) } as Record; + for (const key of ROW_CARRY_KEYS) if (before[key] !== undefined) design[key] = before[key]; for (const key of AUTO_INPUT_KEYS) delete design[key]; return design as unknown as NonNullable; } diff --git a/B06_Section/logic/B06_Section_UI_Page_Patches.ts b/B06_Section/logic/B06_Section_UI_Page_Patches.ts index bd11427e9..28a5ffba6 100644 --- a/B06_Section/logic/B06_Section_UI_Page_Patches.ts +++ b/B06_Section/logic/B06_Section_UI_Page_Patches.ts @@ -22,8 +22,10 @@ export interface CrossPatchSources { stationWidths: Map; inletStructures: Map; basinAdjustments: Map; - revetAdjusts: Map>; - extraCounts: Map; + /** 벽 역할 → 값 · null = 되돌림(바탕 dict 에서 그 벽을 뺌 · 445 M2). */ + revetAdjusts: Map>; + /** 세션에 있는 쪽만(445 H3 — 없는 쪽은 바탕 값 그대로). */ + extraCounts: Map>; /** 다단 단별 구간값(2026-08-29) — 키는 벽 키("extra0"…/"bextra0"…). */ extraSpans: Map>; fordAdjusts: Map; @@ -59,11 +61,12 @@ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch patchFor(Number(chainage)).basin_adjust = adjust; }); // 기슭막이 4축·다단 단 수 — 세션 전용이던 값을 정본에 실어 확정한다(2026-08-24). + // 되돌림(null) · 한쪽 단 수는 바탕과 합칠 때 풀림(`mergeCrossPatches`) — 혼자 나가면 null 걷고 빈 쪽 0 sources.revetAdjusts.forEach((adjusts, chainage) => { - patchFor(chainage).revet_adjust = adjusts; + patchFor(chainage).revet_adjust = adjusts as Record; }); sources.extraCounts.forEach((counts, chainage) => { - patchFor(chainage).extra_wall_counts = counts; + patchFor(chainage).extra_wall_counts = counts as { outlet: number; basin: number }; }); // 다단 단별 구간값 — 세션에서 만진 값을 정본에 싣는다(2026-08-29 사용자). sources.extraSpans.forEach((spans, chainage) => { @@ -83,3 +86,44 @@ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch }); return [...patchByChainage.values()]; } + +/** 벽 · 단 · 쪽 열쇠로 담는 칸 — 세션은 만진 열쇠만 들고 있어 바탕(캐시 설계)과 **열쇠마다** 합침(445 H3). */ +const DICT_KEYS = new Set(["revet_adjust", "extra_spans", "extra_wall_counts"]); + +type Loose = Record; +const isDict = (value: unknown): value is Loose => + !!value && typeof value === "object" && !Array.isArray(value); +/** 안쪽 null = 되돌림 — 그 열쇠를 뺌(서버는 dict 를 통째 갈아 끼움). */ +const withoutNulls = (dict: Loose): Loose => + Object.fromEntries(Object.entries(dict).filter(([, value]) => value !== null)); + +/** + * 저장 patch = 바탕(캐시 설계의 사용자 칸 · 측점마다 통째) 위에 세션 고침을 얹음(445 H2 · H3 · M2). + * 같은 측점 = 누가거리 0.01 m 글. 벽 · 단 · 쪽 dict 는 열쇠마다 합치고 안쪽 null 은 뺌 · 나머지 칸은 세션이 이김. + * 바탕에 없는 측점의 세션 고침도 그대로 나감(안쪽 null 걷고 · 단 수 빈 쪽 0). + */ +export function mergeCrossPatches( + base: readonly CrossSectionPatch[], + overlay: readonly CrossSectionPatch[], +): CrossSectionPatch[] { + const out = new Map(); + const join = (patch: CrossSectionPatch): void => { + const at = patch.chainage_m.toFixed(2); + const into = out.get(at) ?? { chainage_m: patch.chainage_m }; + for (const [key, value] of Object.entries(patch)) { + if (key === "chainage_m" || value === undefined) continue; + into[key] = + DICT_KEYS.has(key) && isDict(value) + ? withoutNulls({ ...(isDict(into[key]) ? into[key] : {}), ...value }) + : value; + } + out.set(at, into); + }; + base.forEach(join); + overlay.forEach(join); + for (const patch of out.values()) { + const counts = patch.extra_wall_counts; + if (isDict(counts)) patch.extra_wall_counts = { outlet: 0, basin: 0, ...counts }; + } + return [...out.values()] as unknown as CrossSectionPatch[]; +} diff --git a/resources/tester/helper_429_save_path.cjs b/resources/tester/helper_429_save_path.cjs index bec0a82f2..94402bd31 100644 --- a/resources/tester/helper_429_save_path.cjs +++ b/resources/tester/helper_429_save_path.cjs @@ -46,6 +46,14 @@ async function run() { saveSections: async (...args) => (sent.push(args.map((a) => a ?? null)), {}), fetchSectionDetail: async () => null, }, + // 445 H5 — 노선 저장 뒤 본문 = 횡단 보기와 같은 칸(세션 고침 · 표준 횡단면 · 풍선) + "./B06_Section_Cross_Design_Session": { crossDesignChoices: () => new Map() }, + "./B06_Section_UI_Page_Persist": { + readRockBoundarySession: () => ({}), + readCutSlopeSession: () => ({}), + }, + "./logic/B06_Section_UI_Page_Patches": { mergeCrossPatches: (a, b) => [...a, ...b] }, + "@util/common_util_mass_haul_balance_view": { balloonOffsetsPayload: () => ({}) }, })(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts")); out.noPatchReturn = await store.saveCachedCrossPatches("p1", 9); out.noPatchSent = sent; diff --git a/resources/tester/helper_445_save_patch.cjs b/resources/tester/helper_445_save_patch.cjs new file mode 100644 index 000000000..178e5a965 --- /dev/null +++ b/resources/tester/helper_445_save_patch.cjs @@ -0,0 +1,194 @@ +/* PLAN 445 헬퍼 — [저장] 횡단 patch 바탕 · 세션 얹음 · 되돌림 · 옮긴 측점 입력. + * 조정창(`createStationControls`)으로 고친 뒤 횡단 보기 [저장] 본문(`buildCrossPatches` → `cachedCrossPatches`)을 + * 그대로 만들어 내놓음 — 시험이 서버 병합(`_apply_section_edits` → `merge_cross_section_design_batches`)에 넣어 + * DB 설계를 견줌. TS 는 그 자리에서 트랜스파일 · 화면 · 서버 창구는 가짜. 짝 = `test_445_save_patch.py`. + * node helper_445_save_patch.cjs + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`); + +const memory = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.sessionStorage = memory(); +globalThis.localStorage = memory(); +globalThis.window = globalThis; + +const anything = () => + new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) }); + +function makeLoader(real, stubs = {}) { + const cache = new Map(); + const realSet = new Set(real.map((file) => path.resolve(file))); + const load = (file) => { + const full = path.resolve(file); + 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 need = (spec) => { + if (spec in stubs) return stubs[spec]; + let target = null; + if (spec.startsWith("@util/")) target = path.join(ROOT, "common_util", `${spec.slice(6)}.ts`); + else if (spec.startsWith(".")) target = path.join(path.dirname(full), `${spec}.ts`); + if (target && realSet.has(path.resolve(target))) return load(target); + return anything(); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; + }; + return load; +} + +const bodyStub = () => ({ + control: { selectedFor: () => false, select: () => undefined }, + byChainage: () => new Map(), + load: () => undefined, +}); +const load = makeLoader( + [ + B06("B06_Section_Section_Store"), + B06("B06_Section_Section_Canon"), + B06("B06_Section_Section_Adjusts"), + B06("B06_Section_Cross_Refresh"), + B06("B06_Section_Api_Culvert_Options"), + B06("B06_Section_UI_Page_Station_Controls"), + B06("B06_Section_UI_Page_Link_Session"), + B06("B06_Section_UI_Cross_Culvert_Types"), + B06("logic/B06_Section_Station_Swap"), + B06("logic/B06_Section_UI_Page_Patches"), + path.join(ROOT, "A00_Common", "b_page_state.ts"), + path.join(ROOT, "A00_Common", "design", "design_selection.ts"), + ], + { + "./B06_Section_Api_Fetch": { + fetchSectionDetail: async () => job.detail, + saveSections: async () => ({}), + }, + "./B06_Section_UI_Page_Ford_Controls": { + createBodyControls: () => ({ ford: bodyStub(), box: bodyStub() }), + }, + "./B06_Section_UI_Page_Span_Control": { + createSpanControl: () => ({ + control: {}, + spanKeyOf: (chainageM, wall) => `${chainageM.toFixed(2)}:${wall}`, + }), + }, + "./B06_Section_Section_Drafts": { + withDraftStations: async () => undefined, + withDraftShapes: async () => undefined, + forgetDraftStations: () => undefined, + supersedeDraftStations: () => undefined, + }, + "./B06_Section_Structure_Layouts": { + applyStructureAreaRows: () => undefined, + structureAreaRows: () => [], + computeStoredLayouts: () => null, + }, + "../B05_Profile/B05_Profile_Api_PlusStations": { + loadSavedPlusStations: async () => undefined, + plusStationsOf: () => [], + }, + }, +); + +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const P = "p445"; +const R = 7; +const { stateKey } = load(path.join(ROOT, "A00_Common", "b_page_state.ts")); +const store = load(B06("B06_Section_Section_Store")); +const { buildCrossPatches } = load(B06("logic/B06_Section_UI_Page_Patches")); +const { createStationControls } = load(B06("B06_Section_UI_Page_Station_Controls")); + +/** 새 탭 = 빈 세션 · 서버 상세(정본) 받아 캐시에 · 조정창 하나. */ +async function freshTab(clearSession = true) { + if (clearSession) globalThis.sessionStorage.clear(); + store.invalidateSectionDetail(P, R); + const detail = await store.loadSectionDetail(P, R); + const controls = createStationControls({ + sessionKey: (kind) => stateKey(kind, P, R), + refreshCard: () => undefined, + detail: () => detail, + crossHalfWidth: () => undefined, + sampledHalfWidth: () => 20, + ensureSampledWidth: async () => true, + projectId: () => P, + }); + controls.load(); + return { detail, controls }; +} + +/** 횡단 보기 [저장] 본문 — 세션 고침(`buildCrossPatches`) → 바탕 위에 얹음(`cachedCrossPatches`). */ +function savePatches(controls) { + const overlay = buildCrossPatches({ + rockOffsets: new Map(), + cutSlopeRatios: new Map(), + stationWidths: controls.widths, + inletStructures: controls.inletStructures, + basinAdjustments: controls.basinAdjustments, + revetAdjusts: controls.revetAdjustsByChainage(), + extraCounts: controls.extraCountsByChainage(), + extraSpans: controls.extraSpansByChainage(), + fordAdjusts: new Map(), + boxAdjusts: new Map(), + linkFlags: controls.linkFlagsByChainage(), + }); + return { overlay, patches: store.cachedCrossPatches(P, R, overlay) }; +} + +(async () => { + const at = job.at; + const out = {}; + + // H3 — 유출만 고침 → 유입 · 집수 쪽 단 수 남음 + { + const { controls } = await freshTab(); + controls.revetOffset.update(at, "outlet", { x: 3 }); + out.h3 = savePatches(controls); + } + // M2 — 새 탭 ↺(유입) · 추가 벽 − · 빈 전체 폭 · 집수정 내공 2.1(M1) + { + const { controls } = await freshTab(); + controls.revetOffset.reset(at, "inlet"); + controls.extraWalls.setCount(at, 0, "outlet"); + controls.applyGlobalWidth(undefined, [at]); + controls.inletStructure.updateAdjust(at, { innerWidthM: 2.1, innerHeightM: 0.5 }); + out.m2 = savePatches(controls); + out.m2.countShown = controls.extraWalls.countFor({ chainage_m: at, design: {} }, "outlet"); + // 저장 전 새로고침(세션은 남고 상세는 정본) — 되돌림이 살아 있어야 함 + const again = await freshTab(false); + out.m2reload = savePatches(again.controls); + out.m2reload.cachedInlet = again.detail.cross_sections[0].design.revet_adjust?.inlet ?? null; + out.m2reload.cachedCounts = again.detail.cross_sections[0].design.extra_wall_counts ?? null; + } + // H2 — 정본 행이 없는 자리(옮긴 관)의 캐시 설계 = 지반 등 입력까지 실음 + { + globalThis.sessionStorage.clear(); + store.invalidateSectionDetail(P, R); + await store.loadSectionDetail(P, R); // 정본 = job.detail(at 한 측점) + const moved = await store.loadSectionDetail(P, R); + moved.cross_sections.push({ ...job.moved }); + out.h2 = store.cachedCrossPatches(P, R, []); + moved.cross_sections.pop(); + } + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_318_cross_card.py b/resources/tester/test_318_cross_card.py index a0614c726..dbd4a722c 100644 --- a/resources/tester/test_318_cross_card.py +++ b/resources/tester/test_318_cross_card.py @@ -245,7 +245,7 @@ def test_wiring_sources() -> None: assert "patchFor(at).structure_reset = true" in persist assert 'clearState("kindreset", ctx.projectId)' in persist store = _src("B06_Section", "B06_Section_Section_Store.ts") - assert "crossPatchesFromCache(detail, kindChangedChainages(projectId))" in store + assert "crossPatchesFromCache(detail, kindChangedChainages(projectId), savedAt)" in store view = _src("A00_Common", "design", "design_view_cross.ts") assert "setStationAdjustReload(() => stationControls.load())" in view @@ -344,7 +344,7 @@ def test_merge_none_only_entry_for_missing_row_inserts_nothing() -> None: def test_apply_edits_resets_before_patches(monkeypatch: pytest.MonkeyPatch) -> None: calls: list[tuple[list, bool]] = [] - async def fake_merge(connection, *, route_id, entries, replace, project_id=None): + async def fake_merge(connection, *, route_id, entries, replace, project_id=None, missed=None): calls.append((list(entries), replace)) return len(entries) diff --git a/resources/tester/test_384_s_area_keys.py b/resources/tester/test_384_s_area_keys.py index 916b22ecc..3ce56dee1 100644 --- a/resources/tester/test_384_s_area_keys.py +++ b/resources/tester/test_384_s_area_keys.py @@ -24,7 +24,7 @@ ROOT = Path(__file__).resolve().parents[2] def test_면적_보내도_정본에_안_적음(monkeypatch): written: list = [] - async def merge(connection, *, route_id, entries, replace, project_id=None): + async def merge(connection, *, route_id, entries, replace, project_id=None, missed=None): written.append((replace, entries)) return 0 @@ -41,7 +41,7 @@ def test_면적_보내도_정본에_안_적음(monkeypatch): def test_면적만_보낸_측점은_patch_없음(monkeypatch): written: list = [] - async def merge(connection, *, route_id, entries, replace, project_id=None): + async def merge(connection, *, route_id, entries, replace, project_id=None, missed=None): written.append(entries) return 0 diff --git a/resources/tester/test_428_pipe_move_mirror.py b/resources/tester/test_428_pipe_move_mirror.py index 57a732ace..b965b9bb0 100644 --- a/resources/tester/test_428_pipe_move_mirror.py +++ b/resources/tester/test_428_pipe_move_mirror.py @@ -3,9 +3,9 @@ 요구 255-1 · 257-1 · 261 · 분석 424 1장 · 7장. 못박는 것 - - 0.5 m 넘게 옮긴 관 측점 = 기본 설계(새 상단측 깎기 · 리핑암 · 표준 측구) - + 사용자가 만진 칸 — 서버가 그 행을 지우고 기본 설계를 세우는 것과 같음 · - 0.5 m 안이면 앞 설계 그대로. + - 0.5 m 넘게 옮긴 관 측점 = 기본 설계(새 상단측 깎기) + 사용자가 만진 칸 + 지반 등 + 입력(지반 · 측구 종류 · 2단 경사 · 암선 — 445 H2 · 서버 `CARRY_INPUT_KEYS`) — 서버가 + 그 행을 지우고 patch 의 그 입력으로 기본 설계를 세우는 것과 같음 · 0.5 m 안이면 앞 설계 그대로. - 저장 노선 다시 셈(`refreshCrossDesigns`)이 미교차 측점을 서버 `extend_unclosed` 와 같이 넓힘 → 브라우저 관 길이 = 서버 관 길이(같은 기하 함수 · 표본 · 설계). - 관 초안 얹기 — 따라가기만 한 관(땅 위 자리 = 정본 x · y) = 서버 누가거리 · @@ -56,25 +56,30 @@ CULVERT = { } +# 옛 자리 사용자 입력 — 옮긴 측점이 들고 감(445 H2 · 서버 `_carried_inputs` → `compute_default_designs`) +CARRIED = {"ground_type": "soil", "two_stage_slope": True, "rock_boundary_offset_m": 9.0} + + def _pipe_section(line, sampler, chainage: float) -> dict: data = _sections(line, sampler, [{"id": "p-a", "chainage_m": chainage, "source": "user"}]) return next(s for s in data["cross_sections"] if abs(s["chainage_m"] - chainage) < 1e-6) def _server(section: dict, elevation: float, sampler): - """서버 길 — 옮긴 관 측점 = 기본 설계(상단측 깎기 · `compute_default_designs`) + """서버 길 — 옮긴 관 측점 = 기본 설계(상단측 깎기 · 들고 온 입력 · `compute_default_designs`) → [저장] 보정 맨 앞 미교차 넓힘(`extend_unclosed_sections` — 저장 설계의 단면 유형 · 측구 칸을 입력으로 다시 셈).""" base = { - "ground_type": "ripping_rock", + "ground_type": CARRIED["ground_type"], "ditch_type": "standard", "standard": STANDARD_CROSS_SECTION, - "rock_boundary_offset_m": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + "rock_boundary_offset_m": CARRIED["rock_boundary_offset_m"], } stored = compute_cross_design( section["samples"], elevation, section_mode=f"{section.get('uphill_side') or 'left'}_cut", + two_stage_slope=CARRIED["two_stage_slope"], **base, ) @@ -140,11 +145,9 @@ def r(tmp_path_factory) -> dict: other = "right" if uphill == "left" else "left" old["design"] = { "design_elevation_m": elevation, - "ground_type": "soil", + **CARRIED, "section_mode": f"{other}_cut", "ditch_side": other, - "two_stage_slope": True, - "rock_boundary_offset_m": 9.0, "inlet_structure": "revet", } job = { @@ -179,10 +182,11 @@ def r(tmp_path_factory) -> dict: def test_옮긴_관_측점_기본_설계와_만진_칸(r): s = r["swapped"] assert s["uphill"] == r["job"]["uphill"] # 상단측 = 새 지반 - assert s["section_mode"] == f"{r['job']['uphill']}_cut" - assert s["ground_type"] == "ripping_rock" - assert s["ditch_side"] is None and s["two_stage_slope"] is None - assert s["rock_boundary_offset_m"] is None + assert s["section_mode"] == f"{r['job']['uphill']}_cut" # 단면 유형 = 새 지형 + assert s["ditch_side"] is None # 측구 방향 = 새 지형(다시 셈) + # 지반 등 입력은 들고 감(445 H2 — 예전엔 리핑암 · 기본 암선으로 돌아가 DB 와 갈림) + assert s["ground_type"] == "soil" and s["two_stage_slope"] is True + assert s["rock_boundary_offset_m"] == 9.0 assert s["inlet_structure"] == "revet" # 사용자가 만진 칸은 따라감 diff --git a/resources/tester/test_429_save_path.py b/resources/tester/test_429_save_path.py index 276fc7854..bf7bc31e0 100644 --- a/resources/tester/test_429_save_path.py +++ b/resources/tester/test_429_save_path.py @@ -48,7 +48,8 @@ def _ground() -> list[dict]: def test_노선_저장_뒤_고침이_없어도_횡단_저장_한_번(out): - assert out["noPatchSent"] == [["p1", 9, None, None]] # 본문 없이 = 서버가 다시 셈만 + # 본문 없이 = 서버가 다시 셈만 · 풍선 자리는 늘 실음(445 H5 — 횡단 보기 [저장]과 같은 본문) + assert out["noPatchSent"] == [["p1", 9, None, None, {"balloon_offsets": {}}]] assert out["noPatchReturn"] == 0 diff --git a/resources/tester/test_445_save_patch.py b/resources/tester/test_445_save_patch.py new file mode 100644 index 000000000..5efa33002 --- /dev/null +++ b/resources/tester/test_445_save_patch.py @@ -0,0 +1,251 @@ +"""PLAN 445 — [저장] 횡단 patch 바탕 · 되돌림 · 옮긴 측점 · 집수정 범위 · 버린 수. + +요구 268-3 · 분석 `docs/raw/조사/2026-10-18_442_요구268_저장누락.md` +(H2 · H3 · M2 · H5 · M1 · M5 · M7). + +못박는 것(조정창으로 고침 → 횡단 보기 [저장] 본문 → 서버 병합 → DB 설계) + - H3 — 새 탭에서 유출 벽만 고쳐도 유입 벽 · 집수 쪽 단 수가 남음(바탕 = 캐시 설계 통째). + - M2 — 새 탭 ↺ · 추가 벽 0 · 빈 전체 폭이 저장분을 지움 · 저장 전 새로고침에도 되돌림이 남음. + - M1 — 집수정 내공 2.1 → 서버 범위 2.0 으로 잘림(422 없음). + - H2 — 정본 행이 없는 자리(옮긴 관)는 지반 등 입력까지 실음 · 서버 기본 설계가 그 입력으로 섬. + - M5 — 행 없는 측점 patch 를 버린 수 · 응답 칸. + - H5 · M7 — 노선 저장 뒤 본문 · kindreset 옮김(소스 배선). +짝: `helper_445_save_patch.cjs`. +""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section import B06_Section_Router_Confirm as confirm # noqa: E402 +from B06_Section.B06_Section_Repository_Bulk import ( # noqa: E402 + merge_cross_section_design_batches, +) +from B06_Section.B06_Section_Schema import ( # noqa: E402 + CrossSectionPatch, + SectionConfirmRequest, + SectionConfirmResponse, +) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +AT = 100.0 +SAVED = { + "ground_type": "soil", + "section_mode": "left_cut", + "structure_note": "메모", + "display_half_width_m": 8.0, + "revet_adjust": { + "inlet": {"x": 1.0, "d": None, "h": None, "m": None}, + "outlet": {"x": 2.0, "d": None, "h": None, "m": None}, + }, + "extra_wall_counts": {"outlet": 1, "basin": 2}, + "basin_adjust": {"innerWidthM": 1.5, "innerHeightM": 1.2, "lateralM": 0, "slopeM": 0}, +} +MOVED = { + "chainage_m": 130.0, + "kind": "irregular", + "design": { + "ground_type": "soil", + "ditch_type": "l_type", + "two_stage_slope": True, + "rock_boundary_offset_m": -1.5, + "structure_note": "옮긴 관 메모", + }, +} + + +@pytest.fixture(scope="module") +def r(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("b445") + job = { + "at": AT, + "detail": { + "longitudinal": {"length_m": 500, "stations": []}, + "cross_sections": [{"chainage_m": AT, "kind": "irregular", "design": SAVED}], + }, + "moved": MOVED, + } + path = work / "job.json" + path.write_text(json.dumps(job, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_445_save_patch.cjs"), str(path)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +class _Cursor: + def __init__(self, connection): + self.connection = connection + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def execute(self, sql, params=None): + self.connection.log.append((" ".join(sql.split()), list(params or []))) + + async def fetchall(self): + return self.connection.rows + + +class _Connection: + def __init__(self, rows): + self.rows = rows + self.log: list = [] + + def cursor(self, *args): + return _Cursor(self) + + +def _server_save(patches: list[dict], rows=None) -> tuple[dict, list[float]]: + """화면 patch → 스키마 → 서버 병합(실제 `merge_cross_section_design_batches`) → 저장된 설계.""" + request = SectionConfirmRequest(cross_patches=patches) + rows = rows or [(1, AT, json.dumps({"design": SAVED}, ensure_ascii=False))] + connection = _Connection(rows) + missed = asyncio.run(confirm._apply_section_edits(connection, 7, request, [])) + updates = [params for sql, params in connection.log if sql.startswith("UPDATE")] + if not updates: + return SAVED, missed + return json.loads(updates[-1][1])["design"], missed + + +def test_H3_한_벽만_고쳐도_다른_벽_단_수_남음(r): + design, _ = _server_save(r["h3"]["patches"]) + assert design["revet_adjust"]["outlet"]["x"] == 3.0 + assert design["revet_adjust"]["inlet"]["x"] == 1.0 # 예전: 유입 0(세션 몫만 보내 통째 교체) + assert design["extra_wall_counts"] == {"outlet": 1, "basin": 2} + assert design["structure_note"] == "메모" and design["display_half_width_m"] == 8.0 + # 세션 몫만으로는 유입이 빠짐 — 바탕이 메운 것 + assert set(r["h3"]["overlay"][0]["revet_adjust"]) == {"outlet"} + old, _ = _server_save(r["h3"]["overlay"]) # 옛 길(세션 몫만) = 유입이 사라짐 + assert "inlet" not in old["revet_adjust"] + + +def test_M2_되돌림이_저장분을_지움(r): + design, _ = _server_save(r["m2"]["patches"]) + assert set(design["revet_adjust"]) == {"outlet"} # ↺ 유입 = 지움 + assert design["extra_wall_counts"] == {"outlet": 0, "basin": 2} # 새 탭 − = 0 · 집수 쪽 그대로 + assert design["display_half_width_m"] == 0 # 빈 전체 폭 = 개별 폭 없음(읽는 쪽 0 이하 = 없음) + assert r["m2"]["countShown"] == 0 + + +def test_M2_저장_전_새로고침에도_되돌림이_남음(r): + reload = r["m2reload"] + assert reload["cachedInlet"] is None # 세션 null 이 정본 벽을 다시 뺌 + assert reload["cachedCounts"] == {"outlet": 0, "basin": 2} + design, _ = _server_save(reload["patches"]) + assert set(design["revet_adjust"]) == {"outlet"} + assert design["extra_wall_counts"] == {"outlet": 0, "basin": 2} + assert design["display_half_width_m"] == 0 + + +def test_M1_집수정_내공_서버_범위로_잘림(r): + basin = next(p for p in r["m2"]["patches"] if "basin_adjust" in p)["basin_adjust"] + assert basin["innerWidthM"] == 2.0 and basin["innerHeightM"] == 1.0 + design, _ = _server_save(r["m2"]["patches"]) + assert design["basin_adjust"]["innerWidthM"] == 2.0 + with pytest.raises(ValidationError): # 서버 범위 그대로(화면이 맞춤) + CrossSectionPatch(chainage_m=AT, basin_adjust={**SAVED["basin_adjust"], "innerWidthM": 2.1}) + + +def test_H2_옮긴_측점은_지반_등_입력까지(r): + moved = next(p for p in r["h2"] if abs(p["chainage_m"] - MOVED["chainage_m"]) < 0.01) + for key in ("ground_type", "ditch_type", "two_stage_slope", "rock_boundary_offset_m"): + assert moved[key] == MOVED["design"][key], key + assert moved["structure_note"] == "옮긴 관 메모" + kept = next(p for p in r["h2"] if abs(p["chainage_m"] - AT) < 0.01) + assert ( + "ground_type" not in kept + ) # 정본 행이 있는 측점은 사용자 칸만(결과 칸 · 입력은 정본 그대로) + # 서버 — 행 없는 자리의 기본 설계를 그 입력으로 세움(`compute_default_designs(carried=…)`) + request = SectionConfirmRequest(cross_patches=r["h2"]) + carried = confirm._carried_inputs(request, [130.004]) + assert carried == { + 130.004: { + "ground_type": "soil", + "ditch_type": "l_type", + "two_stage_slope": True, + "rock_boundary_offset_m": -1.5, + } + } + + +def test_M5_행_없는_측점_patch_버린_수(): + missed: list[float] = [] + connection = _Connection([(1, AT, json.dumps({"design": SAVED}))]) + asyncio.run( + merge_cross_section_design_batches( + connection, + route_id=7, + batches=[ + ([(AT, {"structure_note": "x"}), (250.0, {"structure_note": "y"})], False, None) + ], + missed=missed, + ) + ) + assert missed == [250.0] + assert confirm._default_failed([10.0, 20.0], [(10.004, {})]) == [20.0] + fields = SectionConfirmResponse.model_fields + assert {"dropped_patch_chainages", "default_failed_chainages", "stations_synced"} <= set(fields) + api = (ROOT / "B06_Section" / "B06_Section_Api_Fetch.ts").read_text(encoding="utf-8") + assert "result.dropped_patch_chainages" in api and "stations_synced === false" in api + + +def test_M5_새_행은_버린_수에_안_셈(): + missed: list[float] = [] + connection = _Connection([]) + asyncio.run( + merge_cross_section_design_batches( + connection, + route_id=7, + batches=[([(300.0, {"ground_type": "soil"})], True, uuid4())], + missed=missed, + ) + ) + assert missed == [] + + +def test_H5_노선_저장_뒤_본문_M7_kindreset_옮김(): + store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8") + body = store[store.index("export async function saveCachedCrossPatches") :] + for needle in ( + "sessionOverlay(projectId, routeId)", + "readStandardCrossSession(projectId)", + "balloon_offsets: balloonOffsetsPayload()", + ): + assert needle in body, needle + overlay = store[store.index("function sessionOverlay") :] + for needle in ("crossDesignChoices(", "readRockBoundarySession(", "readCutSlopeSession("): + assert needle in overlay, needle + persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + assert "cachedCrossPatches(ctx.projectId, routeId, edits.crossPatches)" in persist + keys = (ROOT / "B06_Section" / "B06_Section_Station_Keys.ts").read_text(encoding="utf-8") + carry = keys[keys.index("export function carryChainageStates") :] + assert 'writeState("kindreset", rekey("revetx", resets), projectId)' in carry + idle = keys[keys.index("function moveIdleStates") :] + assert 'writeState("kindreset", move(resets), projectId)' in idle diff --git a/resources/tester/test_b06_save_fills_empty_design.py b/resources/tester/test_b06_save_fills_empty_design.py index 900407141..a8d0d54b3 100644 --- a/resources/tester/test_b06_save_fills_empty_design.py +++ b/resources/tester/test_b06_save_fills_empty_design.py @@ -72,7 +72,7 @@ def _run_save(monkeypatch: pytest.MonkeyPatch, missing: list[float]) -> list[flo # 행 자체가 없는 측점은 이 시험의 관심사가 아니다 — 빈 목록으로 고정한다. monkeypatch.setattr(confirm, "_rowless_station_chainages", lambda *_a, **_k: []) - def _spy(_root, _path, chainages, _standard): + def _spy(_root, _path, chainages, _standard, _carried=None): seen.extend(chainages) return [(value, {"ground_type": "soil"}) for value in chainages]