From 08e72ab3233c17a2f6082e51f928a8ec573a4b97 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 20:40:03 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20215=20B06=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=EC=A4=84=20=EB=B9=88=EC=9E=90=EB=A6=AC=20=EB=88=84=EB=A6=84?= =?UTF-8?q?=EC=9D=B4=20=EC=B9=B4=EB=93=9C=20=EA=B3=A0=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B9=A0=EC=A7=90(=ED=99=95=EC=9D=B8?= =?UTF-8?q?=EB=A7=8C)?= 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 --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 34c6163b4..f707153c9 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -243,3 +243,4 @@ 212. (원문 · 10-13) 추가 검토사항 - 3D영역의 기본보기를 원근으로 변경 213. (원문 · 10-13) 1. 3D 형상 업데이트 버튼에 변경에 대한 표현은 나오지만 버튼누를경우 형상 업데이트는 안됨. 2. 구조물측점이 정해진 곳에서 다른 구조물로 변경하는 경우에 다시 구조물이 없는 상태에서 시작해서 다시 선택된 구조물로 변경하면 되는데 이게 힘든거야? 이건 너가 직접 정밀분석해서 직접 수정해봐 214. (원문 · 10-13) 구조물 변경에서 아직 겹쳐지는 구조물들이 존재함. 내가 봤을때는 캐시데이터 충돌도 있는것같음. 리프레시하면 최종선택한 구조물로 형상이 변경된 그림이 나옴. 확인만 해보자. (이어서) 너가 직접해 +215. (원문 · 10-13) B06의 횡단도에서 횡단도 하이라이트 기능의 선택영역중에 측구/일반/l형/복합경사/상세 버튼이 존재하는 영역이 누락된것같아. 확인만 해봐. From 6e981b1c90d6cdf8ad4879be60da3779f56ee047 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 20:47:03 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20216=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EB=B0=94=EA=BF=88=20=EC=98=9B=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=EA=B0=92=20=EC=A0=80=EC=9E=A5=20=EC=84=9E=EC=9E=84=20?= =?UTF-8?q?=EB=A7=89=EC=9D=8C=20=C2=B7=20=EC=A4=91=EC=8B=AC=EA=B3=A0=20=3D?= =?UTF-8?q?=20Y=EC=B6=95=20=EB=9D=BC=EB=B2=A8=20=EC=83=81=EC=9E=90=20?= =?UTF-8?q?=C2=B7=20=EC=95=84=EB=9E=98=20=EB=9D=A0=20=EC=97=AC=EC=9C=A0=20?= =?UTF-8?q?=EC=97=86=EC=95=B0=20=C2=B7=20=EB=8B=A8=EC=B6=94=20=EC=A4=84=20?= =?UTF-8?q?=EB=B9=88=EC=9E=90=EB=A6=AC=20=EA=B3=A0=EB=A5=B4=EA=B8=B0(?= =?UTF-8?q?=EB=B8=8C=EB=A0=88=EC=9D=B8=20=EC=A7=81=EC=A0=91)?= 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 --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index f707153c9..b7f6ea25d 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -244,3 +244,4 @@ 213. (원문 · 10-13) 1. 3D 형상 업데이트 버튼에 변경에 대한 표현은 나오지만 버튼누를경우 형상 업데이트는 안됨. 2. 구조물측점이 정해진 곳에서 다른 구조물로 변경하는 경우에 다시 구조물이 없는 상태에서 시작해서 다시 선택된 구조물로 변경하면 되는데 이게 힘든거야? 이건 너가 직접 정밀분석해서 직접 수정해봐 214. (원문 · 10-13) 구조물 변경에서 아직 겹쳐지는 구조물들이 존재함. 내가 봤을때는 캐시데이터 충돌도 있는것같음. 리프레시하면 최종선택한 구조물로 형상이 변경된 그림이 나옴. 확인만 해보자. (이어서) 너가 직접해 215. (원문 · 10-13) B06의 횡단도에서 횡단도 하이라이트 기능의 선택영역중에 측구/일반/l형/복합경사/상세 버튼이 존재하는 영역이 누락된것같아. 확인만 해봐. +216. (원문 · 10-13) 1. 구조물 종류를 바꿀때 옛 측점별 조정값이 저장기록에 섞이지 않게 해줘. 2. 횡단도의 중심고 표현이 횡단도 하단 띠 좌측에 표현되고 있는데 이걸 띠에서는 삭제하고 그래프 영역의 Y축 라벨에 같이 반영해주는데 박스와 테마에 맞춰 잘보이는 색상의 폰트 색상과 사각라운드를 활용하여 표기해줘. 이때 폰트를 크게할필요는 없고 다른 Y라벨과 동일한 폰트사이즈로 하고 대신 박스가 Y 축선에 침범하지 않게 해줘. 띠에 있던 글자는 삭제하고 암경계조절을 좌측맞춤으로 해줘. 3. 횡단도의 하단띠 하단에 여유공간이 있음. 띠의 높이는 조절하지말고 여유를 없애줘. 아마 컨테이너 외곽이 올라가능 방향이 될거야. 4. 횡단도 하이라이트 영역 조정은 너의 제안대로 해줘. 4. 이것도 너가 직접 작업해줘. From 6329ea2a3af7d0d15b6b03a96039ef6f72b0f851 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 21:24:30 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(b06):=20=ED=9A=A1=EB=8B=A8=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=E2=80=94=20=EB=8B=A8=EC=B6=94=20=EC=A4=84=20?= =?UTF-8?q?=EB=B9=88=EC=9E=90=EB=A6=AC=20=EB=88=84=EB=A6=84=20=3D=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B3=A0=EB=A6=84=20=C2=B7=20=EC=A4=91?= =?UTF-8?q?=EC=8B=AC=EA=B3=A0=20=3D=20Y=EC=B6=95=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EB=91=A5=EA=B7=BC=20=EC=83=81=EC=9E=90(=EB=88=88=EA=B8=88?= =?UTF-8?q?=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EA=B8=80=EC=9E=90=20=C2=B7=20?= =?UTF-8?q?=EC=86=8C=EC=88=98=20=EC=B2=AB=EC=A7=B8=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=B6=95=EC=84=A0=20=EC=95=9E=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A9=88=EC=B6=A4=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=C2=B7=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=AA=A8=EB=8B=AC)=20=C2=B7=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20=EB=9D=A0=20=EC=A4=91=EC=8B=AC=EA=B3=A0=20=EA=B8=80?= =?UTF-8?q?=20=EB=BA=8C=20=C2=B7=20=EC=95=94=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EC=99=BC=EC=AA=BD=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=3D=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EB=86=92=EC=9D=B4(=EC=95=84=EB=9E=98=20?= =?UTF-8?q?=EC=97=AC=EC=9C=A0=20=EC=97=86=EC=95=B0=20=C2=B7=20=EB=B9=88=20?= =?UTF-8?q?=EB=9D=A0=20=EB=86=92=EC=9D=B4=20=EA=B7=B8=EB=8C=80=EB=A1=9C)?= =?UTF-8?q?=20(318-1~3=20=C2=B7=20=EC=9A=94=EA=B5=AC=20215=20=C2=B7=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 --- B06_Section/B06_Section_UI_Cross_Axes.ts | 48 +++++++++++++++++-- .../B06_Section_UI_Cross_Card_Chrome.ts | 6 +-- B06_Section/B06_Section_UI_Cross_Design.ts | 8 +++- B06_Section/B06_Section_UI_Cross_View.ts | 1 + B06_Section/B06_Section_UI_Section_View.ts | 1 + B06_Section/B06_Section_UI_Style_Cross.css | 33 ++++++++----- 6 files changed, 76 insertions(+), 21 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Axes.ts b/B06_Section/B06_Section_UI_Cross_Axes.ts index f7e5d72b2..72b8c20cf 100644 --- a/B06_Section/B06_Section_UI_Cross_Axes.ts +++ b/B06_Section/B06_Section_UI_Cross_Axes.ts @@ -26,6 +26,8 @@ export interface CrossAxesOptions { /** 표고 과장 기준점과 배율 — 화면 표고 → 실제 표고 환산에 쓴다. */ elevationMid: number; exaggeration: number; + /** 중심고(실제 표고) — Y축 라벨 자리에 둥근 상자로(318-2 · 소수 첫째 자리). 없으면 안 그림. */ + centerZ?: number | null; } /** X 눈금 라벨("-12" 등)이 겹치지 않는 최소 간격(px). */ @@ -36,6 +38,11 @@ const X_TARGET_TICKS = 7; /** 표고(Y) 눈금 최소 간격(m) — 1m 아래 단위는 표기하지 않는다. */ const MIN_ELEVATION_STEP_M = 1; const Y_TARGET_TICKS = 10; +/** Y 눈금 글 오른끝 ↔ Y 축선(px). */ +const Y_LABEL_GAP_PX = 7; +/** 중심고 상자 — 글자 좌우 여백 · 높이(px). 글자 크기는 눈금과 같음(10px). */ +const CENTER_PAD_X = 3; +const CENTER_BOX_H = 14; /** * 축 레이어를 그리는 함수를 만든다. 줌·팬이 바뀔 때마다 그 상태로 다시 부르면 된다. @@ -108,9 +115,16 @@ export function createCrossAxes( MIN_ELEVATION_STEP_M, ); const yDecimals = Math.max(0, Math.ceil(-Math.log10(yStep) - 1e-9)); + const screenYOf = (raw: number) => + ty + scale * y(options.elevationMid + (raw - options.elevationMid) * options.exaggeration); + // 중심고 상자 자리 — 보이는 범위 안일 때만 · 그 높이 눈금 글자는 상자에 양보 + const centerZ = options.centerZ; + const centerY = + typeof centerZ === "number" && centerZ >= rawBottom && centerZ <= rawTop + ? screenYOf(centerZ) + : null; for (let raw = Math.ceil(rawBottom / yStep) * yStep; raw <= rawTop + 1e-9; raw += yStep) { - const display = options.elevationMid + (raw - options.elevationMid) * options.exaggeration; - const screenY = ty + scale * y(display); + const screenY = screenYOf(raw); layer.append( svgElement("line", { x1: plotLeft, @@ -119,13 +133,41 @@ export function createCrossAxes( y2: screenY, class: "b06-chart__grid", }), + ); + if (centerY !== null && Math.abs(screenY - centerY) < CENTER_BOX_H) continue; + layer.append( svgText(raw.toFixed(yDecimals), { - x: plotLeft - 7, + x: plotLeft - Y_LABEL_GAP_PX, y: screenY + 3, "text-anchor": "end", class: "b06-chart__tick", }), ); } + if (centerY !== null) appendCenterTag(layer, (centerZ as number).toFixed(1), plotLeft, centerY); }; } + +/** 중심고 둥근 상자 — 글 오른끝 = 다른 눈금 자리 · 상자는 Y 축선(`axisX`) 4px 앞에서 멈춤 · + * 글 폭은 잴 수 있으면 재고(붙은 뒤) 아니면 어림. */ +function appendCenterTag(layer: SVGGElement, text: string, axisX: number, centerY: number): void { + const textRight = axisX - Y_LABEL_GAP_PX; + const box = svgElement("rect", { + y: centerY - CENTER_BOX_H / 2, + height: CENTER_BOX_H, + rx: 3, + ry: 3, + class: "b06-chart__center-box", + }); + const label = svgText(text, { + x: textRight, + y: centerY + 3.5, + "text-anchor": "end", + class: "b06-chart__tick b06-chart__center-text", + }); + layer.append(box, label); + const measured = (label as SVGTextElement).getComputedTextLength?.() ?? 0; + const width = (measured > 0 ? measured : text.length * 5.8) + CENTER_PAD_X * 2; + box.setAttribute("x", String(textRight + CENTER_PAD_X - width)); + box.setAttribute("width", String(width)); +} diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index 063233d09..e3e67923c 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -172,11 +172,9 @@ export function appendCardFooter( rockBoundary?: RockBoundaryControl, cutSlope?: CutSlopeControl, ): void { + // 중심고는 그래프 Y축 라벨 상자로 옮김(318-2 · `_Cross_Axes`) — 하단 띠는 암 제어만 · 비어도 띠 높이 그대로 const footer = document.createElement("footer"); - const center = document.createElement("span"); - center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; - footer.append(center); - // 암 경계선 제어는 하단 정보 행 가운데(2026-08-02) — 암 지반만. + // 암 경계선 제어는 하단 정보 행 왼쪽(318-2) — 암 지반만. if (rockBoundary && section.design?.geometry_preset === "rock") { const rockControl = buildRockBoundaryControl(section, rockBoundary); rockControl.classList.add("b06-cross-card__rockb"); diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 88b037ea8..2fc78b270 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -346,8 +346,12 @@ export function buildDesignControls( }; const bar = document.createElement("div"); bar.className = "b06-design"; - // 컨트롤 상호작용이 카드 선택 클릭으로 전파되지 않게 한다. - bar.addEventListener("click", (event) => event.stopPropagation()); + // 조작(단추 · 입력 · ⋯ 펼침)만 카드 선택 클릭으로 안 넘김 — 빈자리 누름은 카드를 고름(318-1). + bar.addEventListener("click", (event) => { + if ((event.target as Element).closest("button, input, select, label, details")) { + event.stopPropagation(); + } + }); // 경사 방향 선택이 필요한 경우 = 양절(both_cut)·양성(both_fill). 편절은 절토측 자동. // 양성은 측구가 없어도 노면 기울기 방향(생략된 측구 방향)을 여기서 지정한다(N-2-3). diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index dd7f71188..b78abd6d5 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -387,6 +387,7 @@ export function createCrossSectionCard( displayMax, elevationMid, exaggeration, + centerZ: section.center_z ?? null, }); renderAxes(cardZoomStates.get(section.station_id) ?? IDENTITY_VIEW); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index e0570dda1..ebe180f12 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -599,6 +599,7 @@ export function createSectionView( displayMax: plot.displayMax, elevationMid: plot.elevationMid, exaggeration: Math.max(currentExaggeration, 0.1), + centerZ: section.center_z ?? null, }; // 칸 맞춤이 넓힌 가로 범위를 처음 배율로 되좁힘 = 카드 반폭(266-1) const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1; diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index e0288d254..2b23f1c65 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -121,6 +121,8 @@ display: grid; grid-template-columns: repeat(auto-fill, minmax(min(480px, 100%), 1fr)); gap: var(--spacing-16); + /* 카드 = 내용 높이만큼 — 같은 줄 큰 카드에 맞춰 늘어나 아래 띠 밑이 비던 것(318-3 · 띠 높이 그대로) */ + align-items: start; } /* 알림 줄(138-3) — 카드 칸 맨 위 고정 · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸. @@ -344,6 +346,11 @@ html.is-bm-route .b06-section__notice-bar { border-top: 1px solid var(--color-border); } +/* 빈 띠(토사 측점 · 중심고는 Y축으로 옮김 318-2)도 글 한 줄 높이 그대로 */ +.b06-cross-card > footer:empty::before { + content: "\200b"; +} + .b06-section__empty { display: flex; min-height: 180px; @@ -388,6 +395,18 @@ html.is-bm-route .b06-section__notice-bar { font-size: 10px; } +/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 테마 강조색 · 축선 앞에서 멈춤 */ +.b06-chart__center-box { + fill: color-mix(in srgb, var(--color-accent) 14%, var(--color-surface-raised)); + stroke: var(--color-accent); + stroke-width: 1; +} + +.b06-chart__center-text { + fill: var(--color-accent); + font-weight: var(--font-weight-medium); +} + /* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */ .b06-chart__structure-label { font-size: 9px; @@ -607,24 +626,14 @@ html.is-bm-route .b06-section__notice-bar { font-family: var(--font-mono); } -/* 암 경계선 제어(E-7) — 하단 정보 행 **가운데 맞춤**(2026-08-06 사용자 지시). - 방위각 삭제로 space-between 3분할 배치가 깨져 좌측에 붙었다 — 절대 배치로 - 행 중앙에 고정한다(중심고·반폭 버튼과 겹치지 않는 폭). */ -.b06-cross-card > footer { - position: relative; -} - +/* 암 경계선 제어(E-7) — 하단 정보 행 **왼쪽 맞춤**(318-2 · 중심고는 그래프 Y축 라벨 상자로 옮김). */ .b06-cross-card__rockb { - position: absolute; - left: 50%; - transform: translateX(-50%); flex: 0 0 auto; border-radius: var(--radius-inputs); padding: 1px 4px; } -/* 측점별 암 절토 경사(2026-09-07) — 암 경계선이 행 가운데를 절대 배치로 쓰므로 - 이 칸은 행의 **오른쪽 끝**에 둔다. 중심고(왼쪽)와 겹치지 않는다. */ +/* 측점별 암 절토 경사(2026-09-07) — 행의 **오른쪽 끝**(암 경계 = 왼쪽). */ .b06-cross-card__cutslope { margin-left: auto; flex: 0 0 auto; From 6d914b7647b6665d7d55f5652dc8083fcf4c4826 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 21:24:37 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(b06):=20=EA=B4=80=20=EC=A2=85=EB=A5=98?= =?UTF-8?q?=20=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장).