From 6d914b7647b6665d7d55f5652dc8083fcf4c4826 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 21:24:37 +0900 Subject: [PATCH] =?UTF-8?q?fix(b06):=20=EA=B4=80=20=EC=A2=85=EB=A5=98=20?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=3D=20=EA=B7=B8=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EC=98=9B=20=EC=A1=B0=EC=A0=95=EA=B0=92(=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=95=84=ED=99=89=20=EC=B9=B8=20=C2=B7=20=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=EB=9C=AC=20=ED=9A=A1=EB=8B=A8=20=EC=82=AC=EB=B3=B8)=20?= =?UTF-8?q?=EB=B2=84=EB=A6=BC=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20=EB=95=8C?= =?UTF-8?q?=20structure=5Freset=20=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=B3=B8?= =?UTF-8?q?=20=EC=98=9B=20=EC=A1=B0=EC=A0=95=EA=B0=92=20=EB=B9=84=EC=9B=80?= =?UTF-8?q?=20=C2=B7=20=EA=B0=99=EC=9D=80=20=ED=96=89=20=EC=97=AC=EB=9F=AC?= =?UTF-8?q?=20=EC=A4=84=20=EC=9D=B4=EC=96=B4=20=EC=96=B9=EC=9D=8C(CASE=20?= =?UTF-8?q?=EC=B2=AB=20WHEN=20=EB=A7=8C=20=EC=93=B0=EB=8D=98=20=EA=B2=83)?= =?UTF-8?q?=20(318-4=20=C2=B7=20=EC=9A=94=EA=B5=AC=20216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- A00_Common/b_page_state.ts | 35 +- A00_Common/design/design_view_cross.ts | 2 + .../B05_Profile_UI_Structures_Panel_Events.ts | 4 + .../B06_Section_Api_Culvert_Options.ts | 111 +++- B06_Section/B06_Section_Api_Types.ts | 2 + B06_Section/B06_Section_Repository_Bulk.py | 21 +- B06_Section/B06_Section_Router_Confirm.py | 8 +- B06_Section/B06_Section_Router_Design.py | 15 + B06_Section/B06_Section_Schema.py | 3 + B06_Section/B06_Section_Section_Drafts.ts | 18 + B06_Section/B06_Section_Section_Store.ts | 16 +- B06_Section/B06_Section_UI_Page_Persist.ts | 12 +- resources/tester/helper_318_cross_card.cjs | 503 ++++++++++++++++++ resources/tester/test_318_cross_card.py | 291 ++++++++++ resources/tester/test_b06_design_key_split.py | 5 +- 15 files changed, 1012 insertions(+), 34 deletions(-) create mode 100644 resources/tester/helper_318_cross_card.cjs create mode 100644 resources/tester/test_318_cross_card.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index a6c86b1af..30571f6dc 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -137,6 +137,8 @@ export const STATE_REGISTRY = { selection: { bucket: "draft", scope: "project" }, /** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */ "structure-type-cache": { bucket: "draft", scope: "project" }, + /** 종류를 바꾼 관 자리 `{누가거리: 처음 종류}`(318-4) — [저장] 때 그 측점 옛 조정값을 정본에서도 비움. */ + kindreset: { bucket: "draft", scope: "project" }, /** 배수관·암거 조정창에서 예약한 옵션 값. */ culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` }, /** 배수관 이동(측점 옮김) 예약. */ @@ -485,29 +487,36 @@ export function clearState( routeId?: number | string | null, ): void { const entry = entryOf(name); - // 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다. + // 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다(`routeStateKeys`). // 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`), // 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169 // 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 — // 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다. if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) { - const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; - const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`; - try { - const doomed: string[] = []; - for (let i = 0; i < window.sessionStorage.length; i += 1) { - const key = window.sessionStorage.key(i); - if (key && key.startsWith(prefix)) doomed.push(key); - } - doomed.forEach((key) => writeRaw(key, null)); - } catch { - /* 무시 — 저장소가 막힌 브라우저 */ - } + routeStateKeys(name, projectId).forEach((key) => writeRaw(key, null)); return; } writeStateRaw(name, null, projectId, routeId); } +/** 노선 범위 이름의 그 프로젝트 키 전부(모든 노선 · 세션에 있는 것만). */ +export function routeStateKeys(name: StateName, projectId: string): string[] { + const entry = entryOf(name); + if (entry?.scope !== "route") return []; + const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; + const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`; + const keys: string[] = []; + try { + for (let i = 0; i < window.sessionStorage.length; i += 1) { + const key = window.sessionStorage.key(i); + if (key && key.startsWith(prefix)) keys.push(key); + } + } catch { + /* 무시 — 저장소가 막힌 브라우저 */ + } + return keys; +} + /** 등록표에서 한 통에 속한 이름만 고른다. */ export function namesInBucket(bucket: StateBucket): StateName[] { return (Object.keys(STATE_REGISTRY) as StateName[]).filter( diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index f94e999ee..2c603a687 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -29,6 +29,7 @@ import { type StandardCrossSection, } from "../../B06_Section/B06_Section_Api_Fetch"; import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls"; +import { setStationAdjustReload } from "../../B06_Section/B06_Section_Api_Culvert_Options"; import { createCutSlopeStore, createRockBoundaryStore, @@ -304,6 +305,7 @@ export async function mountCrossView( wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120); }, }); + setStationAdjustReload(() => stationControls.load()); // 종류 바꿈이 세션을 비운 뒤(318-4) // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); // 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김 diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index e59a91ff8..392223cf9 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -15,6 +15,7 @@ import { readState, writeState } from "../A00_Common/b_page_state"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "../A00_Common/design/design_selection"; import { readGroupItems, readSummaryItem } from "../common_util/common_util_summary_item"; import { showConfirmDialog } from "@ui/ui_template_elements"; +import { forgetStationAdjusts } from "../B06_Section/B06_Section_Api_Culvert_Options"; /** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */ /** 배선에 필요한 값 — 저장 쪽(`StructuresCommitContext`)과 겹치는 것은 그대로 물려받는다. */ @@ -112,6 +113,9 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { ctx.groupBoxes.setShapes(back?.options ?? {}); ctx.syncFacilityForm(back?.options, pipeItem?.design_flow_m3s ?? null); // 유량 = 월류 높이(65-4) ctx.liveCommit(); + // 관 지점 종류 바꿈 — 그 측점 옛 조정값(세션)은 버리고 [저장] 때 정본에서도 비움(318-4) + if (pipeItem) + forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id, next.type_id); // 반영이 막혀(측점 등) 기록이 그대로면 옛 기록으로 되돌림 if (record && draft && ctx.structures[index] === draft) ctx.structures[index] = record; } diff --git a/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index 5a9cb4a35..5464eadfb 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -10,6 +10,10 @@ * 조작·데이터 흐름 정책, 2026-08-29 사용자 확정). 종전 800ms 디바운스 자동 PUT은 * 폐지했다 — 저장을 누르지 않았는데 영구저장소가 바뀌면 [초기화]의 기준선이 흐려진다. * 세션에 담는 이유는 B06에서 만진 값을 B05 [저장]으로도 내보내야 하기 때문이다. + * + * 관 지점 종류를 바꾸면(318-4) 그 측점에 쌓인 옛 종류 조정값은 뜻을 잃음 — 세션 몫은 바꾸는 + * 자리에서 버리고(`forgetStationAdjusts`) 정본 몫은 [저장] 때 `structure_reset` 으로 비움. + * 열쇠 목록 `STRUCTURE_ADJUST_KEYS` = 서버 `B06_Section_Router_Design` 짝(거울 시험 test_318_kind_reset). * ========================================================================== */ import { @@ -17,6 +21,16 @@ import { saveDetailPipePoints, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + readByKey, + readState, + routeStateKeys, + writeByKey, + writeState, + type StateName, +} from "../A00_Common/b_page_state"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { forgetPreviewAdjusts } from "./B06_Section_Section_Drafts"; /** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로. * 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */ @@ -37,21 +51,17 @@ const keyOf = (chainageM: number): string => chainageM.toFixed(2); function readPending(sessionKey: string | null): PendingMap { if (!sessionKey) return {}; try { - const raw = window.sessionStorage.getItem(sessionKey); + const raw = readByKey(sessionKey); return raw ? (JSON.parse(raw) as PendingMap) : {}; } catch { return {}; } } +/** 세션 저장 실패는 `writeByKey` 가 삼킴 — 값은 화면 캐시에 남아 있다. */ function writePending(sessionKey: string | null, pending: PendingMap): void { - if (!sessionKey) return; - try { - if (Object.keys(pending).length === 0) window.sessionStorage.removeItem(sessionKey); - else window.sessionStorage.setItem(sessionKey, JSON.stringify(pending)); - } catch { - /* 세션 저장 실패는 무시 — 값은 화면 캐시에 남아 있다. */ - } + if (sessionKey) + writeByKey(sessionKey, Object.keys(pending).length ? JSON.stringify(pending) : null); } /** @@ -81,6 +91,91 @@ export async function flushCulvertOptions( writePending(sessionKey, {}); } +/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */ +export const STRUCTURE_ADJUST_KEYS = [ + "inlet_structure", + "basin_adjust", + "revet_adjust", + "ford_adjust", + "box_adjust", + "extra_wall_counts", + "extra_spans", + "revet_link_detached", + "revet_follow_grade", +] as const; + +/** 측점 조정값 세션 — 열쇠가 누가거리(`b` · `:역할` 붙은 것 포함)로 시작함. */ +const STATION_ADJUST_STATES: readonly StateName[] = [ + "culvertopt", + "revetx", + "basinadjust", + "inletstruct", + "extrawall", + "extraspan", + "revetlink", + "fordadjust", + "boxadjust", +]; + +let reloadStationAdjusts: (() => void) | null = null; + +/** 횡단 보기가 메모리 사본을 세션에서 다시 읽는 창구(한 페이지 하나 · 나중 것이 씀). */ +export function setStationAdjustReload(reload: () => void): void { + reloadStationAdjusts = reload; +} + +const atChainage = (key: string, chainageM: number): boolean => { + const head = /^b?(-?\d+(?:\.\d+)?)/.exec(key); + return head !== null && Math.abs(Number(head[1]) - chainageM) < PIPE_MATCH_M; +}; + +function dropAt(map: unknown, chainageM: number): void { + if (!map || typeof map !== "object") return; + const entries = map as Record; + for (const key of Object.keys(entries)) if (atChainage(key, chainageM)) delete entries[key]; +} + +/** + * 관 지점 종류를 바꿈(318-4) — 그 측점 옛 조정값(세션 · 미리 뜬 횡단 사본)을 버리고, [저장] 때 + * 정본에서도 비울 자리로 적음. 처음 종류로 되돌리면 그 자리는 지움(저장분 그대로). + */ +export function forgetStationAdjusts( + projectId: string | null, + chainageM: number, + was: string, + next: string, +): void { + if (!projectId || was === next) return; + for (const name of STATION_ADJUST_STATES) + for (const key of routeStateKeys(name, projectId)) { + const raw = readByKey(key); + if (raw === null) continue; + try { + const stored = JSON.parse(raw) as Record; + if (name === "revetlink") { + dropAt(stored.detached, chainageM); + dropAt(stored.grade, chainageM); + } else dropAt(stored, chainageM); + writeByKey(key, JSON.stringify(stored)); + } catch { + /* 손상된 세션 값은 그대로 — 읽는 쪽이 버림 */ + } + } + const marks = readState>("kindreset", projectId) ?? {}; + const hit = Object.keys(marks).find((key) => atChainage(key, chainageM)); + const first = hit === undefined ? was : marks[hit]; + if (hit !== undefined) delete marks[hit]; + if (next !== first) marks[chainageM.toFixed(2)] = first; + writeState("kindreset", Object.keys(marks).length ? marks : null, projectId); + forgetPreviewAdjusts(projectId, chainageM, STRUCTURE_ADJUST_KEYS); + reloadStationAdjusts?.(); +} + +/** [저장] 때 옛 조정값을 정본에서 비울 측점 — 종류를 바꾼 관 자리(누가거리). */ +export function kindChangedChainages(projectId: string | null): number[] { + return Object.keys(readState>("kindreset", projectId) ?? {}).map(Number); +} + /** * 저장기 하나를 만든다. `projectId`·`sessionKey`가 없으면 예약만 메모리에 남는다 * (조회 전용 상태). `onError`는 [저장] 시점 실패를 화면에 알리는 쪽이 구현한다. diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index f19214b97..9a19ca636 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -645,6 +645,8 @@ export interface CrossSectionPatch { structure_note_cad?: CrossNoteCad; /** [상세] 표 손질(179-4) — 세션 초안 그대로. */ detail_table?: CrossDetailTable; + /** 관 종류를 바꾼 측점(318-4) — 서버가 옛 종류 조정값(`STRUCTURE_ADJUST_KEYS`)을 먼저 비움. */ + structure_reset?: boolean; } /** [상세] 표 칸 출처(179-4) — area = 설계 면적 칸(표 키) · length = 두 끝 [offset, 표고](m) · text = 글. */ diff --git a/B06_Section/B06_Section_Repository_Bulk.py b/B06_Section/B06_Section_Repository_Bulk.py index c2a83e27f..72ebf0944 100644 --- a/B06_Section/B06_Section_Repository_Bulk.py +++ b/B06_Section/B06_Section_Repository_Bulk.py @@ -37,7 +37,7 @@ async def merge_cross_section_designs( """측점 여러 곳의 `data.design` 을 한 번에 쓴다. 실제로 바뀐 행 수를 돌려준다. `replace=True` 면 design 을 통째로 갈아 끼우고(`update_cross_section_design` 과 같은 뜻), - `False` 면 키만 얹는다(`merge_cross_section_design_patch` 와 같은 뜻). + `False` 면 키만 얹는다(`merge_cross_section_design_patch` 와 같은 뜻 · 값 None = 그 키 지움). 행이 없는 측점은 `project_id` 가 오면 새로 만든다 — 구조물(비정규) 측점은 B05 확정이 파일만 쓰고 DB 행을 안 만들기 때문이다(한 행짜리 함수와 같은 규칙). @@ -66,14 +66,20 @@ async def merge_cross_section_designs( best = row_id return best - updates: list[tuple[int, str]] = [] + # 행마다 한 벌 — 같은 행에 여러 줄이 오면 앞 줄 결과 위에 얹음(`CASE id` 는 첫 WHEN 만 씀 · + # 318-4 옛 조정값 비움 뒤 새 값) + updates: dict[int, str] = {} inserts: list[tuple[str, int, float, str]] = [] for chainage_m, payload in entries: if not payload and not replace: continue + # 얹기의 None = 그 열쇠 지움(JSON merge-patch · 318-4 관 종류 바꿈 옛 조정값) + drop = [] if replace else [key for key, value in payload.items() if value is None] + if drop: + payload = {key: value for key, value in payload.items() if value is not None} row_id = find(chainage_m) if row_id is None: - if project_id is None: + if project_id is None or not (payload or replace): continue inserts.append( ( @@ -91,8 +97,11 @@ async def merge_cross_section_designs( design = data.get("design") design = dict(design) if isinstance(design, dict) else {} design.update(payload) + for key in drop: + design.pop(key, None) data["design"] = design - updates.append((row_id, json.dumps(data, ensure_ascii=False))) + existing[row_id] = data + updates[row_id] = json.dumps(data, ensure_ascii=False) written = 0 async with connection.cursor() as cursor: @@ -100,9 +109,9 @@ async def merge_cross_section_designs( # 한 문장 — `CASE id WHEN … THEN …` 이라 왕복이 한 번이다. cases = " ".join("WHEN %s THEN %s" for _ in updates) params: list[Any] = [] - for row_id, blob in updates: + for row_id, blob in updates.items(): params.extend((row_id, blob)) - params.extend(row_id for row_id, _ in updates) + params.extend(updates) placeholders = ", ".join("%s" for _ in updates) await cursor.execute( f"UPDATE cross_sections SET data = CASE id {cases} END " diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index eff3ebc12..db16f52ab 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -38,6 +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 ( compute_default_designs as _compute_default_designs, ) @@ -135,12 +136,17 @@ async def _apply_section_edits( # 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합. if request and request.cross_patches: patches: list[tuple[float, dict[str, Any]]] = [] + # 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값을 **먼저** 지움(None = 지움) + # · 뒤 patch 가 새 값을 얹음 + resets: list[tuple[float, dict[str, Any]]] = [] for patch_item in request.cross_patches: # 필드를 손으로 나열하지 않는다(2026-09-07) — 나열이 네 곳에 흩어져 있어 새 값을 # 더할 때 한 곳만 빠지면 그 값이 **조용히 사라졌다**(`extra_spans` 실사고 `b6941bd2`). # 스키마가 곧 목록이므로 통째로 덤프하고 **최상위 None 만** 걷는다. # (중첩 None 은 남긴다 — 기슭막이 4축의 `d: null` 은 「자동」이라는 뜻이다.) dumped = patch_item.model_dump() + if dumped.pop("structure_reset", None): + resets.append((patch_item.chainage_m, dict.fromkeys(STRUCTURE_ADJUST_KEYS))) patch: dict[str, Any] = { key: value for key, value in dumped.items() @@ -150,7 +156,7 @@ async def _apply_section_edits( patches.append((patch_item.chainage_m, patch)) # 측점 patch 도 한 문장으로 — 전 측점을 보내는 저장에서 왕복이 측점 수만큼 났다. await merge_cross_section_designs( - connection, route_id=route_id, entries=patches, replace=False + connection, route_id=route_id, entries=resets + patches, replace=False ) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index b9ba394cc..5923171c4 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -177,6 +177,21 @@ USER_TOUCHED_KEYS = ( "berm_depth_right_m", ) +# 구조물(관 · 박스 · 세월교) 조정값 — 측점의 관 종류를 바꾸면 버리는 몫(318-4 · `structure_reset`). +# 브라우저 짝 = `B06_Section_Api_Culvert_Options.ts` 의 `STRUCTURE_ADJUST_KEYS` +# (거울 시험 test_318_kind_reset). +STRUCTURE_ADJUST_KEYS = ( + "inlet_structure", + "basin_adjust", + "revet_adjust", + "ford_adjust", + "box_adjust", + "extra_wall_counts", + "extra_spans", + "revet_link_detached", + "revet_follow_grade", +) + # 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 · # design_line · road_edges 같은 결과 칸과 단면 유형 · 측구 방향(새 노선 지형이 정함) · 구조물 # 조작값은 넘기지 않는다. 받는 쪽은 `compute_default_designs(carried=…)`. diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 0a00c7ec8..e9c31c6a8 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -259,6 +259,9 @@ class CrossSectionPatch(BaseModel): structure_note_cad: CrossNoteCad | None = None # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈 detail_table: CrossDetailTable | None = None + # 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값(`STRUCTURE_ADJUST_KEYS`)을 먼저 비움 + # · design 에 안 씀 + structure_reset: bool | None = None class SectionConfirmRequest(BaseModel): diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 29df199eb..52854a601 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -58,6 +58,24 @@ export function forgetDraftStations(projectId: string): void { for (const key of [...previews.keys()]) if (key.startsWith(`${projectId}:`)) previews.delete(key); } +/** 종류 바꿈(318-4) — 그 자리 미리 뜬 횡단 설계에 얹혔던 조정값 사본을 걷음(조작이 캐시에 쓴 것). */ +export function forgetPreviewAdjusts( + projectId: string, + chainageM: number, + keys: readonly string[], +): void { + for (const [key, section] of previews) { + if ( + !key.startsWith(`${projectId}:`) || + !section?.design || + !near(section.chainage_m, chainageM) + ) + continue; + const design = section.design as unknown as Record; + for (const name of keys) delete design[name]; + } +} + function savedOf(projectId: string): Promise<[Pipes, StructureInstance[]]> { let hit = saved.get(projectId); if (!hit) { diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index fdbb34314..6ec26a7ce 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -50,6 +50,7 @@ import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structu import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; +import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options"; const cache = new Map(); const pending = new Map>(); @@ -268,12 +269,20 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo * * 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)만 싣는다. */ -export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSectionPatch[] { +export function crossPatchesFromCache( + detail: SectionDetailResponse, + kindChanged: 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); + for (const at of kindChanged) patches.push({ chainage_m: at, structure_reset: true }); for (const section of detail.cross_sections) { const design = section.design; if (!design) continue; const patch: CrossSectionPatch = { chainage_m: section.chainage_m }; + const skip = reset(section.chainage_m); let touched = false; // 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고 // 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이 @@ -282,7 +291,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti const target = patch as unknown as Record; for (const key of USER_TOUCHED_KEYS) { const value = source[key]; - if (value === undefined || value === null) continue; + if (value === undefined || value === null || (skip && structureKeys.includes(key))) continue; target[key] = value; touched = true; } @@ -298,8 +307,9 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti export async function saveCachedCrossPatches(projectId: string, routeId: number): Promise { const detail = cache.get(keyOf(projectId, routeId)); if (!detail) return 0; - const patches = crossPatchesFromCache(detail); + const patches = crossPatchesFromCache(detail, kindChangedChainages(projectId)); if (!patches.length) return 0; await saveSections(projectId, routeId, undefined, patches); + clearState("kindreset", projectId); // 비울 자리를 보냈음(318-4) return patches.length; } diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index db7fb66a3..0ae24a0b0 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -17,7 +17,14 @@ import { import { invalidateSectionDetail } from "./B06_Section_Section_Store"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; -import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; +import { + clearState, + readByKey, + readState, + writeByKey, + writeState, +} from "../A00_Common/b_page_state"; +import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options"; import { BERM_DEFAULT_DEPTH_M, BERM_DEFAULT_INTERVAL_M, @@ -436,6 +443,8 @@ export function collectSectionEdits(ctx: SectionPersistContext): { if (sides.left !== undefined) patch.berm_depth_left_m = sides.left; if (sides.right !== undefined) patch.berm_depth_right_m = sides.right; }); + // 관 종류를 바꾼 측점(318-4) — 서버가 옛 종류 조정값을 먼저 비우고 이 patch 값만 얹음 + for (const at of kindChangedChainages(ctx.projectId)) patchFor(at).structure_reset = true; if (detail) { // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다 // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). @@ -487,6 +496,7 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave { edits.crossPatches.length ? edits.crossPatches : undefined, edits.massHaul, ); + clearState("kindreset", ctx.projectId); // 비울 자리를 보냈음(318-4) }, done: () => { // 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을 diff --git a/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs new file mode 100644 index 000000000..06008298a --- /dev/null +++ b/resources/tester/helper_318_cross_card.cjs @@ -0,0 +1,503 @@ +/* PLAN 318 단위검증 헬퍼 — B06 횡단 카드: 단추 줄 빈자리 고름 · 중심고 Y축 상자 · 빈 아래 띠 · + * 관 종류 바꿈 = 옛 측점 조정값 버림(세션 · 미리 뜬 횡단 · [저장] patch). + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림(서버 · 무거운 화면 모듈은 흉내) · 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_318_cross_card.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 B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`); + +// ── 화면 그릇 흉내 — 쓰는 만큼만 ───────────────────────────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = String(tag).toLowerCase(); + this.children = []; + this.attrs = {}; + this.listeners = {}; + this.parent = null; + this.dataset = {}; + this.style = { setProperty: () => {} }; + this._text = ""; + this._classes = new Set(); + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + remove: (...names) => names.forEach((n) => self._classes.delete(n)), + toggle: (name, force) => { + const on = force === undefined ? !self._classes.has(name) : !!force; + on ? self._classes.add(name) : self._classes.delete(name); + return on; + }, + contains: (name) => self._classes.has(name), + }; + } + set className(value) { + this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); + } + get className() { + return [...this._classes].join(" "); + } + set textContent(value) { + this._text = String(value); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + get childElementCount() { + return this.children.length; + } + append(...kids) { + kids.forEach((kid) => { + if (typeof kid === "string") { + const text = new FakeNode("#text"); + text._text = kid; + kid = text; + } + if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid); + kid.parent = this; + this.children.push(kid); + }); + } + appendChild(kid) { + this.append(kid); + return kid; + } + prepend(...kids) { + const rest = this.children; + this.children = []; + this.append(...kids, ...rest); + } + replaceChildren(...kids) { + this.children.forEach((k) => (k.parent = null)); + this.children = []; + this.append(...kids); + } + remove() { + if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this); + this.parent = null; + } + setAttribute(name, value) { + this.attrs[name] = String(value); + if (name === "class") this.className = value; + } + getAttribute(name) { + return name === "class" ? this.className : (this.attrs[name] ?? null); + } + removeAttribute(name) { + delete this.attrs[name]; + } + hasAttribute(name) { + return name in this.attrs; + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + removeEventListener() {} + matches(selector) { + return selector + .split(",") + .map((part) => part.trim()) + .some((part) => + part.startsWith(".") ? this._classes.has(part.slice(1)) : this.tagName === part, + ); + } + closest(selector) { + for (let node = this; node; node = node.parent) if (node.matches(selector)) return node; + return null; + } + querySelectorAll(selector) { + const out = []; + const walk = (node) => + node.children.forEach((kid) => { + if (kid.matches(selector)) out.push(kid); + walk(kid); + }); + walk(this); + return out; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } + /** 붙기 전 = 0 — 상자 폭은 어림으로 감. */ + getComputedTextLength() { + return 0; + } +} + +/** 누름 — 눌린 자리에서 위로 올라가며 듣개를 부름(stopPropagation 이면 멈춤). */ +function click(target) { + const event = { + type: "click", + target, + stopped: false, + stopPropagation() { + this.stopped = true; + }, + preventDefault() {}, + }; + for (let node = target; node && !event.stopped; node = node.parent) + (node.listeners.click ?? []).forEach((fn) => fn(event)); + return event; +} + +class FakeStorage { + constructor() { + this.map = new Map(); + } + get length() { + return this.map.size; + } + key(index) { + return [...this.map.keys()][index] ?? null; + } + getItem(key) { + return this.map.has(key) ? this.map.get(key) : null; + } + setItem(key, value) { + this.map.set(key, String(value)); + } + removeItem(key) { + this.map.delete(key); + } +} + +globalThis.window = { + sessionStorage: new FakeStorage(), + localStorage: new FakeStorage(), + addEventListener: () => {}, + removeEventListener: () => {}, + setTimeout, + clearTimeout, +}; +globalThis.sessionStorage = window.sessionStorage; +globalThis.localStorage = window.localStorage; +globalThis.requestAnimationFrame = () => 0; +globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; +globalThis.document = { + createElement: (tag) => new FakeNode(tag), + createElementNS: (_ns, tag) => new FakeNode(tag), + addEventListener: () => {}, + removeEventListener: () => {}, + documentElement: new FakeNode("html"), +}; + +const anything = () => + new Proxy({}, { get: (_t, key) => (key === "__esModule" ? false : () => undefined) }); + +/** + * 로더 하나 — `real` 에 든 파일만 진짜로 읽고, 그 밖 상대 경로는 흉내(무거운 화면 사슬을 안 끌어옴). + * `stubs` 는 지정자 그대로 갈음. + */ +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 out = {}; + +// ── 1. 중심고 Y축 상자(318-2) ────────────────────────────────────────────── +{ + const load = makeLoader( + [ + B06("B06_Section_UI_Cross_Axes"), + B06("B06_Section_UI_Section_Common"), + path.join(ROOT, "common_util", "common_util_svg.ts"), + ], + { + "@config/config_frontend": { PIPE_TYPES: [] }, + "../B05_Profile/B05_Profile_UI_IrregularStations": { PIPE_DISPLAY_NAME: {} }, + }, + ); + const { createCrossAxes, IDENTITY_VIEW } = load(B06("B06_Section_UI_Cross_Axes")); + const { CROSS_PAD } = load(B06("B06_Section_UI_Section_Common")); + const base = { + widthPx: 480, + heightPx: 300, + pixelsPerMeter: 10, + maxOffset: 20, + displayMax: 890, + elevationMid: 880, + exaggeration: 1, + }; + const draw = (centerZ) => { + const layer = new FakeNode("g"); + createCrossAxes(layer, { ...base, centerZ })(IDENTITY_VIEW); + return layer; + }; + const layer = draw(876.57); + const box = layer.querySelector(".b06-chart__center-box"); + const tag = layer.querySelector(".b06-chart__center-text"); + const centerY = CROSS_PAD.top + (base.displayMax - 876.57) * base.pixelsPerMeter; + const ticks = layer + .querySelectorAll("text") + .filter( + (t) => !t.classList.contains("b06-chart__center-text") && t.attrs["text-anchor"] === "end", + ); + out.center = { + axis_x: CROSS_PAD.left, + box: box && { + x: Number(box.attrs.x), + width: Number(box.attrs.width), + y: Number(box.attrs.y), + height: Number(box.attrs.height), + rx: Number(box.attrs.rx), + }, + center_y: centerY, + text: tag?.textContent ?? null, + text_classes: tag?.className ?? null, + text_x: tag ? Number(tag.attrs.x) : null, + tick_x: ticks.length ? Number(ticks[0].attrs.x) : null, + tick_near_center: ticks.filter((t) => Math.abs(Number(t.attrs.y) - 3 - centerY) < 14).length, + tick_count: ticks.length, + hidden_when_out_of_view: draw(800).querySelector(".b06-chart__center-box") === null, + absent_without_center: draw(null).querySelector(".b06-chart__center-box") === null, + }; +} + +// ── 2. 아래 띠(318-2 · 318-3) — 중심고 글 없음 · 암 경계 맨 왼쪽 · 토사도 띠 남음 ───── +{ + const control = (cls) => () => { + const node = new FakeNode("div"); + node.className = cls; + return node; + }; + const load = makeLoader([B06("B06_Section_UI_Cross_Card_Chrome")], { + "./B06_Section_UI_Cross_CutSlope": { buildCutSlopeControl: control("cutslope") }, + "./B06_Section_UI_Cross_Design": { + buildRockBoundaryControl: control("rockb"), + buildDesignControls: () => ({ bar: new FakeNode("div"), groundSegment: new FakeNode("div") }), + sectionModeLabel: () => "", + }, + "./B06_Section_UI_Cross_Fit": { fillSlopeLengths: () => [] }, + "./B06_Section_UI_Section_Common": { + L: (key) => (key === "B06_Profile_View_CenterElevation" ? "중심고" : key), + stationLabel: () => "", + structureDisplayName: () => "", + }, + }); + const { appendCardFooter } = load(B06("B06_Section_UI_Cross_Card_Chrome")); + const footerOf = (preset) => { + const card = new FakeNode("article"); + appendCardFooter(card, { center_z: 876.57, design: { geometry_preset: preset } }, {}, {}); + return card.querySelector("footer"); + }; + const rock = footerOf("rock"); + const soil = footerOf("soil"); + out.footer = { + rock_first: rock?.children[0]?.className ?? null, + rock_classes: rock?.children.map((kid) => kid.className) ?? null, + rock_text: rock?.textContent ?? null, + soil_exists: soil !== null, + soil_children: soil?.childElementCount ?? null, + }; +} + +// ── 3. 단추 줄(318-1) — 빈자리 누름 = 카드 고름 · 조작 누름 = 안 넘김 ─────────── +{ + const load = makeLoader([B06("B06_Section_UI_Cross_Design")], { + "@ui/ui_template_locale": { + currentLanguageIndex: 0, + ui_locales: new Proxy({}, { get: (_t, key) => [String(key)] }), + }, + }); + const { buildDesignControls } = load(B06("B06_Section_UI_Cross_Design")); + const { bar } = buildDesignControls( + { + chainage_m: 100, + uphill_side: "left", + design: { ground_type: "soil", section_mode: "left_cut" }, + }, + () => {}, + ); + const card = new FakeNode("article"); + let picked = 0; + card.addEventListener("click", () => (picked += 1)); + card.append(bar); + const pickedBy = (node) => { + const before = picked; + click(node); + return picked - before; + }; + const button = bar.querySelector("button"); + const label = + bar.querySelectorAll("div").find((node) => !node.closest("button, details")) ?? null; + const summary = bar.querySelector("summary"); + out.bar = { + empty: pickedBy(bar), + button: button ? pickedBy(button) : null, + legend: label ? pickedBy(label) : null, + summary: summary ? pickedBy(summary) : null, + }; +} + +// ── 4. 종류 바꿈 = 그 측점 세션 조정값 버림 · 비울 자리 적음(318-4) ───────────── +{ + const previewCalls = []; + const load = makeLoader( + [ + B06("B06_Section_Api_Culvert_Options"), + path.join(ROOT, "A00_Common", "b_page_state.ts"), + path.join(ROOT, "A00_Common", "design", "design_selection.ts"), + ], + { + "./B06_Section_Section_Drafts": { + forgetPreviewAdjusts: (...args) => previewCalls.push([args[0], args[1], [...args[2]]]), + }, + }, + ); + const mod = load(B06("B06_Section_Api_Culvert_Options")); + const store = window.sessionStorage; + const put = (key, value) => store.setItem(key, JSON.stringify(value)); + const get = (key) => JSON.parse(store.getItem(key) ?? "null"); + put("aislo:draft:culvertopt:P:7", { "100.00": { inlet_revet_length_m: 3 }, "140.00": { a: 1 } }); + put("aislo:draft:culvertopt:P:8", { "100.00": { a: 1 } }); + put("aislo:draft:culvertopt:Q:7", { "100.00": { a: 1 } }); + put("aislo:draft:revetx:P:7", { + "100.00:inlet": { x: 1 }, + "100.00:extra0": { x: 2 }, + "140.00:inlet": { x: 3 }, + }); + put("aislo:draft:extrawall:P:7", { "100.00": 2, "b100.00": 1, "140.00": 1 }); + put("aislo:draft:extraspan:P:7", { "100.00:extra0": { length_m: 1 } }); + put("aislo:draft:revetlink:P:7", { + detached: { "100.00": true, "140.00": true }, + grade: { "100.00": true }, + }); + put("aislo:draft:fordadjust:P:7", { "100.00": { w: 1 } }); + put("aislo:draft:boxadjust:P:7", { "100.30": { w: 1 } }); + put("aislo:draft:basinadjust:P:7", { "100.00": { d: 1 } }); + put("aislo:draft:inletstruct:P:7", { "100.00": "basin" }); + put("aislo:draft:crossw:P:7", { "100.00": 5 }); + let reloads = 0; + mod.setStationAdjustReload(() => (reloads += 1)); + mod.forgetStationAdjusts("P", 100, "pipe", "box_culvert"); + out.forget = { + culvertopt: get("aislo:draft:culvertopt:P:7"), + culvertopt_other_route: get("aislo:draft:culvertopt:P:8"), + culvertopt_other_project: get("aislo:draft:culvertopt:Q:7"), + revetx: get("aislo:draft:revetx:P:7"), + extrawall: get("aislo:draft:extrawall:P:7"), + extraspan: get("aislo:draft:extraspan:P:7"), + revetlink: get("aislo:draft:revetlink:P:7"), + fordadjust: get("aislo:draft:fordadjust:P:7"), + boxadjust: get("aislo:draft:boxadjust:P:7"), + basinadjust: get("aislo:draft:basinadjust:P:7"), + inletstruct: get("aislo:draft:inletstruct:P:7"), + crossw: get("aislo:draft:crossw:P:7"), + kindreset: get("aislo:draft:kindreset:P"), + changed: mod.kindChangedChainages("P"), + preview_calls: previewCalls, + reloads, + }; + mod.forgetStationAdjusts("P", 100, "box_culvert", "ford_bridge"); + out.forget.second = get("aislo:draft:kindreset:P"); + mod.forgetStationAdjusts("P", 100, "ford_bridge", "pipe"); + out.forget.reverted = get("aislo:draft:kindreset:P"); + const before = store.length; + mod.forgetStationAdjusts("P", 140, "pipe", "pipe"); + out.forget.same_kind_untouched = + store.length === before && get("aislo:draft:culvertopt:P:7")["140.00"] !== undefined; + out.forget.keys = [...mod.STRUCTURE_ADJUST_KEYS]; +} + +// ── 5. 미리 뜬 횡단 사본 걷음(318-4) — 그 자리 조정값만 · 다른 칸 그대로 ───────── +const state = { draftPipes: null, savedPipes: [] }; +const loadDrafts = makeLoader( + [ + B06("B06_Section_Section_Drafts"), + path.join(ROOT, "common_util", "common_util_structure_station.ts"), + path.join(ROOT, "common_util", "common_util_culvert_sets.ts"), + ], + { + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }), + }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => [], + readPendingStructures: () => null, + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes }, + "./B06_Section_Api_Fetch": { + previewStructureSections: async (_p, chainages) => + chainages.map((at) => ({ + chainage_m: at, + kind: "irregular", + design: { ground_type: "soil", ford_adjust: { w: 1 }, revet_adjust: { inlet: { x: 1 } } }, + })), + }, + }, +); + +// ── 6. B05 [저장](캐시 patch) — 종류 바꾼 측점 = structure_reset · 옛 조정값 안 실음 ───── +const loadStore = makeLoader( + [ + B06("B06_Section_Section_Store"), + B06("B06_Section_Api_Culvert_Options"), + path.join(ROOT, "A00_Common", "b_page_state.ts"), + path.join(ROOT, "A00_Common", "design", "design_selection.ts"), + ], + { + "./B06_Section_Cross_Refresh": { + USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"], + }, + }, +); + +(async () => { + const { withDraftStations, forgetPreviewAdjusts } = loadDrafts(B06("B06_Section_Section_Drafts")); + const detail = { cross_sections: [{ chainage_m: 0, kind: "regular", design: {} }] }; + state.draftPipes = [{ chainage_m: 50, facility: "box_culvert" }]; + await withDraftStations(detail, "P", 7); + const at50 = () => detail.cross_sections.find((s) => Math.abs(s.chainage_m - 50) < 0.01); + const had = Object.keys(at50()?.design ?? {}).sort(); + forgetPreviewAdjusts("P", 50, ["ford_adjust", "revet_adjust"]); + forgetPreviewAdjusts("OTHER", 0, ["ground_type"]); + out.preview = { had, after: Object.keys(at50()?.design ?? {}).sort() }; + + const { crossPatchesFromCache } = loadStore(B06("B06_Section_Section_Store")); + out.cache_patches = crossPatchesFromCache( + { + cross_sections: [ + { chainage_m: 100, design: { ford_adjust: { w: 1 }, display_half_width_m: 6 } }, + { chainage_m: 140, design: { ford_adjust: { w: 2 } } }, + ], + }, + [100], + ); + out.cache_patches_plain = crossPatchesFromCache({ + cross_sections: [{ chainage_m: 140, design: { ford_adjust: { w: 2 } } }], + }); + 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 new file mode 100644 index 000000000..61e759fe6 --- /dev/null +++ b/resources/tester/test_318_cross_card.py @@ -0,0 +1,291 @@ +"""PLAN 318 — B06 횡단 카드(요구 215 · 216). + +- 318-1 단추 줄: 빈자리 누름 = 카드 고름 · 단추 · ⋯ 펼침 누름 = 카드로 안 넘김 +- 318-2 중심고: 아래 띠에서 빼고 Y축 라벨 둥근 상자(눈금과 같은 글자 · 소수 첫째 자리 · + 축선 안 침범) · 암 경계 = 띠 왼쪽 +- 318-3 아래 띠 아래 여유: 카드 = 내용 높이(같은 줄 큰 카드에 안 늘어남) · 빈 띠도 높이 그대로 +- 318-4 관 종류 바꿈: 그 측점 옛 조정값(세션 · 미리 뜬 횡단 사본)을 버리고 + [저장] 때 정본에서도 비움 + +화면 TS 는 `helper_318_cross_card.cjs` 가 Node 로 돌린다(무거운 화면 사슬 · 서버 부름은 흉내). +""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +import subprocess + +import pytest + +from B06_Section import B06_Section_Router_Confirm as confirm +from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs +from B06_Section.B06_Section_Router_Design import STRUCTURE_ADJUST_KEYS, USER_TOUCHED_KEYS +from B06_Section.B06_Section_Schema import SectionConfirmRequest + +HERE = os.path.dirname(__file__) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +NODE_READY = os.path.isdir(os.path.join(ROOT, "config", "node_modules")) + + +def _src(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def result() -> dict: + if not NODE_READY: + pytest.skip("프론트엔드 의존성 미설치") + out = subprocess.run( + ["node", os.path.join(HERE, "helper_318_cross_card.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + cwd=ROOT, + timeout=120, + ) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +# ── 318-1 단추 줄 ───────────────────────────────────────────────────────── + + +def test_bar_empty_area_picks_card(result: dict) -> None: + bar = result["bar"] + assert bar["empty"] == 1 # 단추 줄 빈자리 + assert bar["legend"] == 1 # 단추 묶음 바탕(단추 아닌 자리) + + +def test_bar_controls_do_not_pick_card(result: dict) -> None: + assert result["bar"]["button"] == 0 + assert result["bar"]["summary"] == 0 # ⋯ 펼침 + + +# ── 318-2 중심고 Y축 상자 ────────────────────────────────────────────────── + + +def test_center_tag_on_y_axis(result: dict) -> None: + center = result["center"] + box = center["box"] + assert center["text"] == "876.6" # 소수 첫째 자리 + assert "b06-chart__tick" in center["text_classes"] # 다른 Y 라벨과 같은 글자 크기 + assert box["rx"] > 0 # 둥근 상자 + assert box["x"] + box["width"] < center["axis_x"] # Y 축선 안 침범 + assert box["x"] > 0 + assert abs(box["y"] + box["height"] / 2 - center["center_y"]) < 0.01 # 중심고 높이에 + assert center["text_x"] == center["tick_x"] # 글 오른끝 = 다른 눈금 자리 + assert center["tick_near_center"] == 0 # 겹치는 눈금 글은 비켜 줌 + assert center["tick_count"] > 3 + assert center["hidden_when_out_of_view"] + assert center["absent_without_center"] + + +def test_footer_without_center_text(result: dict) -> None: + footer = result["footer"] + assert "중심고" not in footer["rock_text"] + assert "876" not in footer["rock_text"] + assert footer["rock_first"].split()[-1] == "b06-cross-card__rockb" # 암 경계 = 왼쪽 첫 칸 + assert footer["rock_classes"][-1].split()[-1] == "b06-cross-card__cutslope" + assert footer["soil_exists"] and footer["soil_children"] == 0 # 토사도 띠는 남음(빈 띠) + + +def test_center_wired_into_card_and_modal() -> None: + assert "centerZ: section.center_z ?? null" in _src( + "B06_Section", "B06_Section_UI_Cross_View.ts" + ) + assert "centerZ: section.center_z ?? null" in _src( + "B06_Section", "B06_Section_UI_Section_View.ts" + ) + + +# ── 318-2 · 318-3 양식 ────────────────────────────────────────────────────── + + +def _rule(css: str, selector: str) -> str: + match = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert match, selector + return match.group(1) + + +def test_css_layout() -> None: + css = _src("B06_Section", "B06_Section_UI_Style_Cross.css") + assert "align-items: start" in _rule(css, ".b06-section__grid") # 카드 = 내용 높이 + rock = _rule(css, ".b06-cross-card__rockb") + assert "position: absolute" not in rock and "translateX" not in rock # 왼쪽 맞춤 + assert 'content: "\\200b"' in _rule(css, ".b06-cross-card > footer:empty::before") + box = _rule(css, ".b06-chart__center-box") + assert "var(--color-accent)" in box # 테마 색 + assert "font-size" not in _rule(css, ".b06-chart__center-text") # 글자 크기 = 눈금 그대로 + + +# ── 318-4 관 종류 바꿈 ───────────────────────────────────────────────────── + + +def test_forget_session_adjusts_at_station(result: dict) -> None: + forget = result["forget"] + assert forget["culvertopt"] == {"140.00": {"a": 1}} + assert forget["culvertopt_other_route"] == {} # 노선 모두 + assert forget["culvertopt_other_project"] == {"100.00": {"a": 1}} # 다른 프로젝트 그대로 + assert forget["revetx"] == {"140.00:inlet": {"x": 3}} + assert forget["extrawall"] == {"140.00": 1} # `b` 머리(집수정 단)까지 + assert forget["extraspan"] == {} + assert forget["revetlink"] == {"detached": {"140.00": True}, "grade": {}} + assert forget["fordadjust"] == {} and forget["boxadjust"] == {} # 100.30 = 같은 관(0.51 안) + assert forget["basinadjust"] == {} and forget["inletstruct"] == {} + assert forget["crossw"] == {"100.00": 5} # 구조물 아닌 값(표시 반폭)은 그대로 + assert forget["reloads"] == 1 # 횡단 보기 메모리 사본 다시 읽음 + assert forget["preview_calls"][0] == ["P", 100, list(STRUCTURE_ADJUST_KEYS)] + + +def test_kind_reset_marks(result: dict) -> None: + forget = result["forget"] + assert forget["kindreset"] == {"100.00": "pipe"} + assert forget["changed"] == [100] + assert forget["second"] == {"100.00": "pipe"} # 처음 종류를 기억 + assert forget["reverted"] is None # 처음 종류로 되돌림 = 저장분 그대로 + assert forget["same_kind_untouched"] + + +def test_preview_copy_stripped(result: dict) -> None: + assert result["preview"]["had"] == ["ford_adjust", "ground_type", "revet_adjust"] + assert result["preview"]["after"] == ["ground_type"] + + +def test_b05_save_path_patches(result: dict) -> None: + assert result["cache_patches"] == [ + {"chainage_m": 100, "structure_reset": True}, + {"chainage_m": 100, "display_half_width_m": 6}, # 옛 ford_adjust 안 실음 + {"chainage_m": 140, "ford_adjust": {"w": 2}}, + ] + assert result["cache_patches_plain"] == [{"chainage_m": 140, "ford_adjust": {"w": 2}}] + + +def test_wiring_sources() -> None: + events = _src("B05_Profile", "B05_Profile_UI_Structures_Panel_Events.ts") + live = events.index("ctx.liveCommit();\n // 관 지점 종류 바꿈") + assert "forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id" in events[live:] + persist = _src("B06_Section", "B06_Section_UI_Page_Persist.ts") + 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 + view = _src("A00_Common", "design", "design_view_cross.ts") + assert "setStationAdjustReload(() => stationControls.load())" in view + + +def test_structure_keys_mirror() -> None: + """서버 · 브라우저 열쇠 목록이 같음 · 모두 승계 목록 안.""" + ts = _src("B06_Section", "B06_Section_Api_Culvert_Options.ts") + block = ts[ts.index("export const STRUCTURE_ADJUST_KEYS = [") :] + block = block[: block.index("] as const")] + assert re.findall(r'"([a-z_]+)"', block) == list(STRUCTURE_ADJUST_KEYS) + assert set(STRUCTURE_ADJUST_KEYS) <= set(USER_TOUCHED_KEYS) + assert "structure_note" not in STRUCTURE_ADJUST_KEYS # 사용자 글 · 그림은 안 버림 + + +# ── 318-4 서버 — 비움이 먼저 · 같은 행은 이어 얹음 · None = 지움 ─────────────── + + +class _Cursor: + def __init__(self, rows): + self.rows = rows + self.calls = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def execute(self, sql, params=None): + self.calls.append((" ".join(sql.split()), list(params) if params else [])) + + async def fetchall(self): + return self.rows + + +class _Connection: + def __init__(self, rows): + self.cursor_obj = _Cursor(rows) + + def cursor(self): + return self.cursor_obj + + +def _written(connection: _Connection) -> dict: + for sql, params in connection.cursor_obj.calls: + if sql.startswith("UPDATE cross_sections SET data = CASE"): + count = len(params) // 3 + return {params[i * 2]: json.loads(params[i * 2 + 1]) for i in range(count)} + return {} + + +def test_merge_none_deletes_and_same_row_accumulates() -> None: + stored = { + "design": { + "ground_type": "soil", + "ford_adjust": {"w": 1}, + "revet_adjust": {"inlet": {"x": 1}}, + "display_half_width_m": 6, + } + } + connection = _Connection([(7, 100.0, json.dumps(stored))]) + asyncio.run( + merge_cross_section_designs( + connection, + route_id=1, + entries=[ + (100.0, dict.fromkeys(STRUCTURE_ADJUST_KEYS)), + (100.0, {"box_adjust": {"w": 2}}), + ], + replace=False, + ) + ) + written = _written(connection) + assert list(written) == [7] # 한 행 한 벌 + assert written[7]["design"] == { + "ground_type": "soil", + "display_half_width_m": 6, + "box_adjust": {"w": 2}, + } + + +def test_merge_none_only_entry_for_missing_row_inserts_nothing() -> None: + connection = _Connection([]) + asyncio.run( + merge_cross_section_designs( + connection, + route_id=1, + entries=[(50.0, dict.fromkeys(STRUCTURE_ADJUST_KEYS))], + replace=False, + project_id="00000000-0000-0000-0000-000000000001", + ) + ) + assert not any("INSERT" in sql for sql, _ in connection.cursor_obj.calls) + + +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): + calls.append((list(entries), replace)) + return len(entries) + + monkeypatch.setattr(confirm, "merge_cross_section_designs", fake_merge) + request = SectionConfirmRequest( + cross_patches=[ + {"chainage_m": 100, "structure_reset": True, "box_adjust": {"left": {"lengthM": 1}}}, + {"chainage_m": 140, "display_half_width_m": 6}, + ] + ) + asyncio.run(confirm._apply_section_edits(None, 1, request, [])) + entries = [entries for entries, replace in calls if not replace][-1] + assert entries[0] == (100, dict.fromkeys(STRUCTURE_ADJUST_KEYS)) # 비움이 먼저 + rest = dict(entries[1:]) + assert "structure_reset" not in rest[100] # design 에 안 씀 + assert rest[100]["box_adjust"]["left"]["lengthM"] == 1 # 새 값은 뒤에 얹힘 + assert rest[140] == {"display_half_width_m": 6} diff --git a/resources/tester/test_b06_design_key_split.py b/resources/tester/test_b06_design_key_split.py index 3dc1d0089..68e99c78d 100644 --- a/resources/tester/test_b06_design_key_split.py +++ b/resources/tester/test_b06_design_key_split.py @@ -26,8 +26,9 @@ from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS # noqa: E402 from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402 -#: 측점을 가리키는 열쇠 — 값이 아니다. -_KEY_FIELDS = {"chainage_m"} +#: 측점을 가리키는 열쇠 · 저장 안 하는 지시 칸 — 값이 아니다. +#: `structure_reset`(318-4) = 옛 구조물 조정값을 비우라는 지시 · 서버가 떼어 내고 design 에 안 씀. +_KEY_FIELDS = {"chainage_m", "structure_reset"} #: 사용자 값이 아니라 **계산 값**이라 재계산이 다시 내는 것이 옳은 필드. #: 브라우저가 조작 중 값을 함께 보내지만 정본은 서버가 다시 낸다(CLAUDE.md 5장).