From cfdcf0aed7a19903fc2e2895a01ea776d431a061 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 15:13:30 +0900 Subject: [PATCH] =?UTF-8?q?fix(472-5=20=EB=82=A8=EC=9D=8C):=20=EC=95=94?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EC=95=94=20=EA=B2=BD=EC=82=AC=20=C2=B7=20?= =?UTF-8?q?B05=20=EC=83=81=EB=8B=A8=EC=B8=A1=20=EB=A9=94=EB=AA=A8=EB=A6=AC?= =?UTF-8?q?=20=EB=A7=B5=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=92=A4=20=EC=98=9B=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EC=B9=B8=20=C2=B7=20hold=20=EB=B9=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 암 경계 · 암 절토 경사 저장소(`B06_Section_UI_Page_Persist`) = 세션 칸이 주인 — 읽을 때마다 측점 열쇠 칸(`readCells`) · 고칠 때 지금 칸을 읽어 그 측점만 바꿔 씀(`putCell`) · 메모리 맵 · load · 갈아끼움 다시 읽기(keyedStores · reloadKeyedStores) 걷음 · ↶ 뒤 다음 조작이 되돌림을 덮던 흠(N5) 고침 - 저장 재료 = `offsets()` · `ratios()`(새 Map) · ↶ ↷ 가 rockb · cutslope 칸을 바꾸면 조정창과 같은 onRestore 로 카드 · 면적 다시 - B05 상단측 메모리 맵(uphillOverrides · load/saveUphillOverrides) 걷음 — 읽는 곳 없이 저장 뒤 비우기만 하던 사본 · 값 주인 = 세션 칸 `uphill` · 상단측 차례 = 기본 조각 그대로 - 노선 바뀐 [저장] 성공 뒤(페이지 다시 세운 뒤) 옛 노선 번호의 노선 범위 초안(`clearRouteDrafts`) · `route-draft-hold` 비움 · 넘김 규칙 그대로 · 같은 번호면 노선 칸 안 지움 · 실패면 그대로 - 시험 test_472_5r_rock_owner 7(옛 코드 빨강 5) · 옛 단언 고침(176_3 · 271 · 443 상단측 조각 · 344_3 저장소 load) - 위키 암선 · 암 경사 · ↶ ↷ 규칙 · 노선 저장 뒤 정리 줄 Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_page_state.ts | 7 + A00_Common/design/design_save.ts | 5 +- A00_Common/design/design_view_cross.ts | 6 +- A00_Common/design/design_view_profile.ts | 10 -- B05_Profile/B05_Profile_UI_Page_Actions.ts | 13 +- B05_Profile/B05_Profile_UI_Page_Helpers.ts | 31 ---- B06_Section/B06_Section_UI_Page_Persist.ts | 148 ++++++++---------- .../B06_Section_UI_Page_Station_Controls.ts | 7 +- .../concepts/design_page/operations_b06.md | 2 +- .../concepts/design_page/operations_common.md | 2 +- docs/wiki/concepts/design_page/rules.md | 2 +- .../wiki/concepts/design_page/session_keys.md | 2 +- resources/tester/helper_271_save_same.cjs | 6 +- .../tester/helper_344_3_station_keys.mjs | 3 +- resources/tester/helper_472_5r_rock.mjs | 121 ++++++++++++++ resources/tester/helper_472_5r_rock_entry.ts | 7 + resources/tester/helper_472_5r_route_save.cjs | 68 ++++++++ resources/tester/test_176_3_no_uphill_lamp.py | 7 +- resources/tester/test_271_save_same.py | 9 +- resources/tester/test_443_save_stops.py | 4 +- resources/tester/test_472_5r_rock_owner.py | 142 +++++++++++++++++ 21 files changed, 438 insertions(+), 164 deletions(-) create mode 100644 resources/tester/helper_472_5r_rock.mjs create mode 100644 resources/tester/helper_472_5r_rock_entry.ts create mode 100644 resources/tester/helper_472_5r_route_save.cjs create mode 100644 resources/tester/test_472_5r_rock_owner.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 403b37745..b7d5234e5 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -587,6 +587,13 @@ export function clearDrafts(projectId: string | null, routeId?: number | string namesInBucket("draft").forEach((name) => clearState(name, projectId, routeId)); } +/** 한 노선 번호의 노선 범위 초안만 비움 — 노선 저장 뒤 옛 노선 칸(서버가 새 노선에 넘겼음 · 472-5). */ +export function clearRouteDrafts(projectId: string, routeId: number): void { + namesInBucket("draft") + .filter((name) => entryOf(name)?.scope === "route") + .forEach((name) => clearState(name, projectId, routeId)); +} + /** 계산 결과를 통째로 버린다 — 입력(설계값·노선)이 바뀌어 다시 만들어야 할 때. */ export function clearResults(projectId: string | null, routeId?: number | string | null): void { namesInBucket("result").forEach((name) => clearState(name, projectId, routeId)); diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 9ca714cdb..eda24c8cf 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -39,7 +39,7 @@ import { type SavePartKey, type SaveStepKey, } from "./design_save_api"; -import { clearState, stateKey } from "../b_page_state"; +import { clearRouteDrafts, clearState, stateKey } from "../b_page_state"; import { pendingRouteSave, rebuildAfterRouteSave, @@ -226,6 +226,9 @@ export async function saveDesign( if (confirm) showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info"); else await ctx.done?.(false); await rebuildAfterRouteSave(ctx.projectId); + // 옛 노선 칸 · 초안 전 hold = 서버가 새 노선에 넘겼음 — 쓰레기만 비움(넘김 규칙 그대로 · 세션 넘침 388-3) + if (saved.route_id !== routeId) clearRouteDrafts(ctx.projectId, routeId); + clearState("route-draft-hold", ctx.projectId); return true; } catch (error) { ctx.failed(oneBodyError(error), confirm); diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 1adb723e3..9711831d4 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -536,8 +536,8 @@ export async function mountCrossView( reconcileDesigns: () => reconcileStaleDesigns({ force: true }), redraw: () => (host.visible() ? refreshDetailForStructures() : Promise.resolve()), patchSources: () => ({ - rockOffsets: rockStore.offsets, - cutSlopeRatios: cutSlopeStore.ratios, + rockOffsets: rockStore.offsets(), + cutSlopeRatios: cutSlopeStore.ratios(), stationWidths: stationControls.widths(), inletStructures: stationControls.inletStructures(), basinAdjustments: stationControls.basinAdjustments(), @@ -631,8 +631,6 @@ export async function mountCrossView( leaveForDashboard(); return; } - rockStore.load(); - cutSlopeStore.load(); stationControls.load(); // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. 숨긴 채 // 섰으면(종단 보기 [확정]) 한 벌 폼은 종단 보기 몫이라 싣지 않는다 — 보일 때 싣는다. diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 885f9865a..57ce81425 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -78,8 +78,6 @@ import { STRUCTURE_ROUTE_DRIFT_MAX_M } from "../../common_util/common_util_route import { DEFAULT_ROAD_WIDTHS, fetchRoadWidths, - loadUphillOverrides, - saveUphillOverrides, toGradeClass, } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections"; @@ -228,11 +226,6 @@ export async function mountProfileView( // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). let selectionSyncing = false; - /* ── 측점 상단측(=측구 방향) 사용자 변경분 ───────────────────────────── - * solve가 자동 판정한 uphill_side를 사용자가 바꾼 값. 세션에 보관했다가 - * 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */ - const uphillOverrides = loadUphillOverrides(activeProjectId); - const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({ stations: () => bridge.irregularStations(), isSyncing: () => selectionSyncing, @@ -415,8 +408,6 @@ export async function mountProfileView( panel: () => panel, profilePanel: () => profilePanel, bridge: () => bridge, - uphillOverrides, - persistUphillOverrides: () => saveUphillOverrides(activeProjectId, uphillOverrides), loadLatest, renderLatest, restoreSections, @@ -424,7 +415,6 @@ export async function mountProfileView( // [저장] · [확정] · [초기화]에 보태는 종단 보기 몫 — 한 벌이 두 보기 몫을 모아 보낸다. bindDesignView(shared, "profile", { save: profileSaveParts(actionContext), - onReset: () => uphillOverrides.clear(), // 계산 묶음(페이지 몫 · 365-1) 알림 중 종단 보기 몫 — 3D 「낡음」 · 관 · 구조물 따라감 pipeline: { onCorridorStale: () => sections.setCorridorPending(true), diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 09b968450..a663ec6be 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -26,9 +26,6 @@ export interface PageActionContext { panel: () => ReturnType; profilePanel: () => ReturnType; bridge: () => ReturnType; - /** 상단측(측구 방향) 사용자 변경분 — 키는 누가거리 문자열. */ - uphillOverrides: Map; - persistUphillOverrides: () => void; loadLatest: (forceFresh?: boolean) => Promise; renderLatest: (next: RouteLatestResponse) => void; restoreSections: (routeId: number) => Promise; @@ -44,15 +41,7 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave { parts: { // 구조물 — 보낼 것 = 세션 초안 ?? 이 보기 목록 · 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다(472-6). structures: () => ctx.bridge().structuresSavePart(), - // 상단측 + 비정규 측점 횡단 맞춤 — 기본 조각 본문 그대로(463) · 저장 뒤 세션(기본) · 메모리 값도 비움. - uphill: (base) => - base && { - body: base.body, - after: async (response) => { - await base.after?.(response); - ctx.uphillOverrides.clear(); - }, - }, + // 상단측 + 비정규 측점 횡단 맞춤 = 기본 조각 그대로(463 · 값 주인 = 세션 칸 `uphill` · 메모리 사본 없음 · 472-5) }, // 횡단 본문은 늘 횡단 보기 몫 — [저장]이 숨긴 채라도 세움(386-6 · 옛 캐시 패치 길 지움) // 공유 캐시는 한 벌(`design_shared`)이 저장 끝에 한 번 비움 — 여기서는 새 자료를 다시 읽어 종단 · diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index cf126dcc4..909c19b14 100644 --- a/B05_Profile/B05_Profile_UI_Page_Helpers.ts +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -7,7 +7,6 @@ * 전부 상태를 갖지 않는 변환 함수라 화면 흐름과 독립적이다. * ========================================================================== */ -import { readState, writeState } from "../A00_Common/b_page_state"; import type { RoutePanelValues } from "./B05_Profile_UI_Panel"; import type { ModelBounds, @@ -224,33 +223,3 @@ export function interpolateIrregularStations( }; }); } - -/* ── 측점 상단측(=측구 방향) 사용자 변경분 세션 보관 ───────────────────── - * 3D 램프 클릭으로 바꾼 값. 경로 확정 때 uphill_overrides로 백엔드에 병합한다. - * 화면 본체가 700줄에 닿아 읽기·쓰기만 여기로 뺐다(2026-09-04, 동작 불변). */ -/** 세션에 남은 상단측 변경분을 읽는다. 손상된 값은 무시하고 빈 것으로 시작한다. */ -export function loadUphillOverrides(projectId: string): Map { - const overrides = new Map(); - try { - const stored = readState>("uphill", projectId); - if (!stored) return overrides; - Object.entries(stored).forEach(([chainage, side]) => { - if (side === "left" || side === "right") overrides.set(chainage, side); - }); - } catch { - /* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */ - } - return overrides; -} - -/** 상단측 변경분을 세션에 적는다. 저장 실패는 무시한다(값은 메모리에 남는다). */ -export function saveUphillOverrides( - projectId: string, - overrides: ReadonlyMap, -): void { - try { - writeState("uphill", Object.fromEntries(overrides), projectId); - } catch { - /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ - } -} diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 09ddd65ea..50b7a048c 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -14,7 +14,6 @@ import { type SectionContextResponse, type SectionDetailResponse, } from "./B06_Section_Api_Fetch"; -import { onAnySwap } from "./B06_Section_Swap_Bus"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { buildCrossPatches, type CrossPatchSources } from "./logic/B06_Section_UI_Page_Patches"; import { @@ -72,21 +71,6 @@ export function readCutSlopeSession(projectId: string, routeId: number): Record< return Object.fromEntries(readKeyed("cutslope", projectId, routeId)); } -/** 지금 사는 암 경계 · 암 경사 저장소 — 측점 목록을 갈아끼우면 새 목록 누가거리로 다시 읽음(346-2 4파). */ -const keyedStores = new Set<{ load: () => void }>(); -function keepAlive void }>(store: T): T { - keyedStores.add(store); - return store; -} - -/** 측점 목록이 갈아끼워짐 — 암 경계 · 암 경사 값을 측점 열쇠 세션에서 다시 읽음. */ -export function reloadKeyedStores(): void { - keyedStores.forEach((store) => store.load()); -} - -// 상세 갈아끼움(어느 보기가 했든)마다 한 번 — 암 경계 · 암 경사를 새 목록 누가거리로 다시 읽음 -onAnySwap(() => reloadKeyedStores()); - /** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */ function keyedScope(storageKey: string): { projectId: string; routeId: number } | null { const [projectId, route] = storageKey.split(":").slice(-2); @@ -109,6 +93,43 @@ function persistKeyed(name: KeyedState, storageKey: string, values: Map string | null, + accept: (value: number) => boolean, +): Map { + const values = new Map(); + const storageKey = sessionKey(); + if (!storageKey) return values; + try { + for (const [chainage, value] of loadKeyed(name, storageKey)) + if (Number.isFinite(value) && accept(value)) values.set(chainage, value); + } catch { + /* 손상된 세션 값은 무시 — 기본값으로 재시작. */ + } + return values; +} + +/** 한 측점 값 바꿈 — 지금 칸을 읽어 그 칸만 바꿔 씀. 세션 저장 실패(용량 초과 등)는 그 조작만 안 남음. */ +function putCell( + name: KeyedState, + sessionKey: () => string | null, + values: Map, + chainageM: number, + value: number, +): void { + const storageKey = sessionKey(); + if (!storageKey) return; + values.set(chainageM.toFixed(2), value); + try { + persistKeyed(name, storageKey, values); + } catch { + /* 무시 */ + } +} + /** * 세션에 쌓인 소단 구간 목록. 없거나 손상되면 빈 목록. * @@ -124,13 +145,11 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp writeState("berm", spans, projectId, routeId); } -/** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ +/** 암 경계선 오프셋 저장소 — 값(세션 칸)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { - /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ - offsets: Map; + /** 지금 세션 칸 — 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 재료(새 Map). */ + offsets: () => Map; control: RockBoundaryControl; - /** 세션값 읽기 — 프로젝트·노선이 정해진 뒤에 부른다. */ - load: () => void; /** context(config) 기본값·스텝 반영. */ setDefaults: (defaultOffsetM: number, stepM: number) => void; } @@ -157,33 +176,13 @@ export function createRockBoundaryStore(options: { * DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다. */ const clamp = (value: number): number => Math.min(value, 0); - const offsets = new Map(); + const offsets = (): Map => readCells("rockb", sessionKey, () => true); const key = (chainageM: number): string => chainageM.toFixed(2); + const put = (chainageM: number, value: number): void => + putCell("rockb", sessionKey, offsets(), chainageM, value); - function persist(): void { - const storageKey = sessionKey(); - if (!storageKey) return; - try { - persistKeyed("rockb", storageKey, offsets); - } catch { - /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ - } - } - - return keepAlive({ + return { offsets, - load(): void { - offsets.clear(); - const storageKey = sessionKey(); - if (!storageKey) return; - try { - loadKeyed("rockb", storageKey).forEach(([chainage, offset]) => { - if (Number.isFinite(offset)) offsets.set(chainage, offset); - }); - } catch { - /* 손상된 세션 값은 무시 — 기본값으로 재시작. */ - } - }, setDefaults(defaultOffsetM: number, stepM: number): void { rockBoundaryDefault = defaultOffsetM; rockBoundaryStep = stepM; @@ -197,7 +196,7 @@ export function createRockBoundaryStore(options: { }, offsetFor: (section) => clamp( - offsets.get(key(section.chainage_m)) ?? + offsets().get(key(section.chainage_m)) ?? section.design?.rock_boundary_offset_m ?? rockBoundaryDefault, ), @@ -205,30 +204,27 @@ export function createRockBoundaryStore(options: { const stored = detail()?.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < 0.01, )?.design?.rock_boundary_offset_m; - const current = clamp(offsets.get(key(chainageM)) ?? stored ?? rockBoundaryDefault); - offsets.set(key(chainageM), clamp(Math.round((current + deltaM) * 100) / 100)); - persist(); + const current = clamp(offsets().get(key(chainageM)) ?? stored ?? rockBoundaryDefault); + put(chainageM, clamp(Math.round((current + deltaM) * 100) / 100)); refreshCard(chainageM); recompute(chainageM); }, reset: (chainageM) => { - offsets.set(key(chainageM), rockBoundaryDefault); - persist(); + put(chainageM, rockBoundaryDefault); refreshCard(chainageM); recompute(chainageM); }, }, - }); + }; } -/** 측점별 암 절토 경사비 저장소 — 값(Map)과 카드 제어기를 함께 낸다(2026-09-07). */ +/** 측점별 암 절토 경사비 저장소 — 값(세션 칸)과 카드 제어기를 함께 낸다(2026-09-07). */ export interface CutSlopeStore { - /** 측점키(누가거리 2자리) → 경사비(1:n 의 n). `buildCrossPatches` 가 그대로 읽는다. + /** 지금 세션 칸 — 측점키(누가거리 2자리) → 경사비(1:n 의 n). `buildCrossPatches` 재료(새 Map). * **0 은 「표준값을 씀」**이다 — 되돌리기(↺)가 남기는 값이라, 저장분에 옛 사용자 값이 * 남아 있어도 0 이 그것을 덮어 표준으로 돌려놓는다(지우기만 하면 옛 값이 되살아난다). */ - ratios: Map; + ratios: () => Map; control: CutSlopeControl; - load: () => void; } /** @@ -249,8 +245,12 @@ export function createCutSlopeStore(options: { recompute: (chainageM: number) => void; }): CutSlopeStore { const { sessionKey, standard, refreshCard, recompute } = options; - const ratios = new Map(); + // 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다. + const ratios = (): Map => + readCells("cutslope", sessionKey, (ratio) => ratio >= 0); const key = (chainageM: number): string => chainageM.toFixed(2); + const put = (chainageM: number, value: number): void => + putCell("cutslope", sessionKey, ratios(), chainageM, value); /** 이 측점이 쓰는 표준 경사비 — 지반 프리셋(암/토사)의 값. */ const standardRatio = (section: { design?: { geometry_preset?: string } | null }): number => { @@ -260,35 +260,12 @@ export function createCutSlopeStore(options: { return typeof value === "number" && value > 0 ? value : 0.4; }; - function persist(): void { - const storageKey = sessionKey(); - if (!storageKey) return; - try { - persistKeyed("cutslope", storageKey, ratios); - } catch { - /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ - } - } - - return keepAlive({ + return { ratios, - load(): void { - ratios.clear(); - const storageKey = sessionKey(); - if (!storageKey) return; - try { - loadKeyed("cutslope", storageKey).forEach(([chainage, ratio]) => { - // 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다. - if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio); - }); - } catch { - /* 손상된 세션 값은 무시 — 표준값으로 재시작. */ - } - }, control: { standardRatioFor: (section) => standardRatio(section), ratioFor: (section) => { - const session = ratios.get(key(section.chainage_m)); + const session = ratios().get(key(section.chainage_m)); if (session === 0) return standardRatio(section); // 되돌림 — 저장분보다 세션이 먼저다. if (typeof session === "number" && session > 0) return session; const stored = (section.design as { cut_slope_ratio_user?: number } | undefined) @@ -298,14 +275,13 @@ export function createCutSlopeStore(options: { }, set: (chainageM, ratio) => { // null = 되돌리기. 지우지 않고 0 을 남겨야 저장분에 있던 옛 값까지 표준으로 돌아간다. - if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) ratios.set(key(chainageM), 0); - else ratios.set(key(chainageM), Math.round(ratio * 10000) / 10000); - persist(); + if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) put(chainageM, 0); + else put(chainageM, Math.round(ratio * 10000) / 10000); refreshCard(chainageM); recompute(chainageM); }, }, - }); + }; } /** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */ diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 350f3e6cb..09ec48789 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -56,7 +56,9 @@ export interface StationControlDeps { | "culvertopt" | "revetlink" | "fordadjust" - | "boxadjust", + | "boxadjust" + | "rockb" + | "cutslope", ) => string | null; refreshCard: (chainageM: number) => void; detail: () => SectionDetailResponse | null; @@ -606,6 +608,9 @@ export function createStationControls(deps: StationControlDeps): StationControls "revetlink", "fordadjust", "boxadjust", + // 암 경계 · 암 경사(카드 칸 · `_UI_Page_Persist` 저장소) — 되돌린 칸으로 카드 · 면적 다시(472-5) + "rockb", + "cutslope", ] as const; const print = (): string => JSON.stringify( diff --git a/docs/wiki/concepts/design_page/operations_b06.md b/docs/wiki/concepts/design_page/operations_b06.md index 37580f494..327ccb5bc 100644 --- a/docs/wiki/concepts/design_page/operations_b06.md +++ b/docs/wiki/concepts/design_page/operations_b06.md @@ -20,7 +20,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 설계선 쉬프트(노견 확장) | 세션 `shoulderext` | 횡단 다시 셈 | — | 횡단 패치 | 5 | `B06_Section_UI_Cross_Design_Pick.ts:402` · `B06_Section_UI_Page_Design_Sync.ts:71-76` · `B06_Section_Cross_Refresh.ts:393-397` | | 소단 구간(구조물) | 구조물 → 시설 줄 `syncBerm` 이 세션 `berm`(같으면 안 씀 · 쓰면 cross 한 번 · B05 만 열어도) | 횡단 다시 셈 · 사본 없으면 저장분 `design.berm` | — | 서버가 `structures.json` 에서(`load_berm_spans`) | 6 | `design_facility_lane.ts:151-166` · `B06_Section_Cross_Refresh.ts:362-368 · 521-522` · `B06_Section_Engine_Structures_Wall.py:124` | | 소단 깊이 ▲▼ | 세션 `bermdepth` | 횡단 다시 셈 | — | 횡단 패치 | 5 | `B06_Section_UI_Cross_Design_Pick.ts:408` · `B06_Section_UI_Page_Design_Sync.ts:78-83` · `B06_Section_Cross_Refresh.ts:513-519` | -| 암선 · 암 경사 | 메모리 맵 + 세션 `rockb` · `cutslope` | 횡단 다시 셈(암 측점 2단 무릎 · 면적) | — | 횡단 패치 · 파이썬 설계선 | 5 | `B06_Section_UI_Cross_Line_Cell.ts:161 · 166` · `B06_Section_UI_Cross_CutSlope.ts:94 · 114` · `B06_Section_Cross_Refresh.ts:229-243 · 306` | +| 암선 · 암 경사 | 세션 칸 `rockb` · `cutslope`(값 주인 · 읽을 때마다 칸 · 메모리 사본 없음 · ↶ ↷ 가 칸을 바꾸면 카드 · 면적 다시) | 횡단 다시 셈(암 측점 2단 무릎 · 면적) | — | 횡단 패치 · 파이썬 설계선 | 5 | `B06_Section_UI_Cross_Line_Cell.ts:161 · 166` · `B06_Section_UI_Cross_CutSlope.ts:94 · 114` · `B06_Section_Cross_Refresh.ts:229-243 · 306` | | 조정창(흙막이 · 집수정 · 유입 · 경간 · 세월교 · BOX · 연동) | 세션 칸(`revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust` · 값 주인 · 메모리 사본 없음 · 창구 = b_page_state 열쇠 `B06_Section_Session_Cells`) · 빈 칸 = 칸 지움 · 관 옵션 = `culvertopt` | 카드 다시 · 구조물 면적 · 누가토량 · ↶ ↷ 가 칸을 바꾸면 상세 얹기(고치기 전 칸 + 지금 칸) · 카드 · 면적 다시 | — | 횡단 패치 · Node 구조물 면적 · 관 옵션 = 관 옵션 차례 `pipe_points` PUT | 6 | `B06_Section_UI_Page_Station_Controls.ts` · `B06_Section_Session_Cells.ts` · `B06_Section_Section_Adjusts.ts` noteAdjustBase · withDraftAdjusts · `design_view_cross.ts` onRestore · `logic/B06_Section_UI_Page_Patches.ts:41-136` | | 반폭 ◀▶ | 측점 = 세션 `crossw` · 전역 = `cross-display` | 표시만 · 횡단 전체 강제 다시 셈(헛일) | — | 측점 반폭만 패치 · `cross-display` 안 감 | 3 | `B06_Section_UI_Cross_View.ts:664-670` · `design_view_cross.ts:251-263 · 448-457` · `B06_Section_Display_Width.ts:20-23` | | 표준횡단 · [전체 반영] | 세션 `std-cross`([폐기]에도 안 지움) | 전 측점 다시 셈(지문에 표준 포함) | — | `standard_cross_section` 으로 · 서버 설계선 다시 | 3 | `B06_Section_UI_Standard_Panel.ts:303-325 · 450-458` · `B06_Section_Api_Fetch.ts:101-111` · `B06_Section_Router_Confirm.py:157-162` | diff --git a/docs/wiki/concepts/design_page/operations_common.md b/docs/wiki/concepts/design_page/operations_common.md index 719b54db4..a1af741f6 100644 --- a/docs/wiki/concepts/design_page/operations_common.md +++ b/docs/wiki/concepts/design_page/operations_common.md @@ -26,7 +26,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ ## [저장] 차례(서버 `B06_Section_Router_DesignSave.py:99-195` · 칸 = `design_save_api.ts` SAVE_PART_KEYS) - [저장] · [확정] = 한 본문 한 요청(472-6) — 브라우저가 차례마다 조각(본문 · 저장 뒤 정리)을 모아 `POST /design/save`(`design_save.ts:183-237`) · 서버가 아래 차례로 안쪽 창구 함수를 부름 · 하나라도 실패 = DB 행 · storage 되돌림(`common_util_save_guard.py`) · 차례 이름째 알림 · 다 저장한 뒤에만 조각 정리 · 같은 프로젝트 저장 겹침 = 뒤 요청 기다림. -- 노선 바뀐 [저장](6-B) = 같은 본문에 `route`(노선 초안) · `carry`(옛 노선 세션 횡단 고침 원본 `routeCarryInputs` `B06_Section_Station_Keys.ts:314`) · 기본 조각만(화면 몫 건너뜀) · 늘 [저장](노선이 바뀐 [확정] = 노선만 저장 · 다시 [확정] 알림) · 되돌림 범위 + 배수 폴더 · 지표면 확정. +- 노선 바뀐 [저장](6-B) = 같은 본문에 `route`(노선 초안) · `carry`(옛 노선 세션 횡단 고침 원본 `routeCarryInputs` `B06_Section_Station_Keys.ts:314`) · 기본 조각만(화면 몫 건너뜀) · 늘 [저장](노선이 바뀐 [확정] = 노선만 저장 · 다시 [확정] 알림) · 되돌림 범위 + 배수 폴더 · 지표면 확정 · 다 저장하고 페이지를 다시 세운 뒤 옛 노선 번호 칸(노선 범위 초안 `clearRouteDrafts`) · `route-draft-hold` 비움(472-5). | 차례 | 브라우저 조각 | 서버 | 노선 바뀐 [저장] | 위치(파일:줄) | |---|---|---|---|---| diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index 09c505fd5..271b5546c 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -54,7 +54,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 | | `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 | | 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠(누가거리 · 관이 따라 옮긴 새 자리와 안 맞으면 안 얹힘) | `design_save.ts:145` · `B06_Section_Router_DesignSave.py` `_patched_points` | 추정 · 옛 길과 같음 | -| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정창 = 세션 칸이 주인(사본 없음) · 칸이 바뀐 걸음만 상세 얹기 · 카드 · 면적 다시(472-5 5-D) · B05 상단측 맵 · 암선 · 암 경사 맵은 아직 옛 값일 수 있음 | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore · `B06_Section_UI_Page_Station_Controls.ts` onRestore | 관 · 구조물 = `test_472_5_shop_owner` · 조정창 = `test_472_5d_adjust_owner` · 나머지 추정 | +| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정창 = 세션 칸이 주인(사본 없음) · 칸이 바뀐 걸음만 상세 얹기 · 카드 · 면적 다시(472-5 5-D) · 암선 · 암 경사 · 상단측 = 세션 칸이 주인(사본 없음 · 472-5) | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore · `B06_Section_UI_Page_Station_Controls.ts` onRestore · `B06_Section_UI_Page_Persist.ts` readCells | 관 · 구조물 = `test_472_5_shop_owner` · 조정창 = `test_472_5d_adjust_owner` · 암선 · 암 경사 = `test_472_5r_rock_owner` | | 스냅 켬 · 간격 = 되돌리기 밖 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` | 추정 | | `flushDesignStore` 부름 없음 — 멈춘 뒤 1 s 안 새로고침 = 가게 세션 사본 잃음(다시 셈) | `design_store.ts:179` | 코드 확인 | | 「[]」 — 되돌리기 = 값 · 떠남 확인 = 빈 칸 | `B05_Profile_UI_Profile_History.ts:50` · `design_leave.ts:41-42` | 코드 확인 | diff --git a/docs/wiki/concepts/design_page/session_keys.md b/docs/wiki/concepts/design_page/session_keys.md index ab4667731..8bc17d3a2 100644 --- a/docs/wiki/concepts/design_page/session_keys.md +++ b/docs/wiki/concepts/design_page/session_keys.md @@ -16,7 +16,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "A00_Common | 열쇠 | 위치(`b_page_state.ts`:줄) | 쓰는 쪽 | [저장] 차례 | |---|---|---|---| -| `route-draft` · `route-draft-hold` | `:218` · `:220` | 노선 편집기 다리 · 초안 전 상세 | route · hold 는 안 감 | +| `route-draft` · `route-draft-hold` | `:218` · `:220` | 노선 편집기 다리 · 초안 전 상세 | route · hold 는 안 감 · 노선 저장 뒤 hold 비움 | | `alignment-draft` | `:213` | 종단 편집(노선 · 초안 노선 0) | alignment | | `pagesettings` | `:215` | 페이지 설정 폼 | settings | | `pipes` · `structures` · `plus-stations` | `:136` · `:127` · `:129` | 시설 줄 · 배수판 · B06 패널 · 추가 측점 창 | pipes · structures · plusStations | diff --git a/resources/tester/helper_271_save_same.cjs b/resources/tester/helper_271_save_same.cjs index 83da6625a..ee60b4b08 100644 --- a/resources/tester/helper_271_save_same.cjs +++ b/resources/tester/helper_271_save_same.cjs @@ -119,19 +119,17 @@ async function run(parts) { seed(true); out.closed = await run(undefined); - // ② 종단 보기를 연 뒤 [저장] = 종단 보기 몫(상단측 차례) — 구조물 몫은 PUT 없음(가짜) + // ② 종단 보기를 연 뒤 [저장] = 종단 보기 몫 — 상단측 차례는 기본 조각 그대로(값 주인 = 세션 칸 · 472-5) seed(true); - const memory = new Map([["40.000", "right"]]); const parts = actions.profileSaveParts({ projectId: P, latest: () => LATEST, confirmedSurface: () => ({ model_id: 7 }), routeReady: () => true, bridge: () => ({ structuresSavePart: async () => null }), - uphillOverrides: memory, }); out.open = await run({ uphill: parts.parts.uphill }); - out.openMemoryAfter = memory.size; + out.openOwnUphill = typeof parts.parts.uphill; // ③ 새로고침해 B06 로 바로 — 노선 캐시 없음 = 서버에서 한 번 읽고 같은 본문 seed(false); diff --git a/resources/tester/helper_344_3_station_keys.mjs b/resources/tester/helper_344_3_station_keys.mjs index 6e889fa18..f9bdad533 100644 --- a/resources/tester/helper_344_3_station_keys.mjs +++ b/resources/tester/helper_344_3_station_keys.mjs @@ -106,8 +106,7 @@ const rock = m.createRockBoundaryStore({ }); rock.control.adjust(20, -0.3); out.rockKeys = keysOf("rockb", R); -rock.load(); -out.rockLoaded = Object.fromEntries(rock.offsets); +out.rockLoaded = Object.fromEntries(rock.offsets()); out.rockSession = m.readRockBoundarySession(P, R); /* 3. 구조물 id 를 아직 모를 때(첫 열기) — 쓴 때 자리(@at)로 그 측점 고침을 읽음 */ diff --git a/resources/tester/helper_472_5r_rock.mjs b/resources/tester/helper_472_5r_rock.mjs new file mode 100644 index 000000000..cab4b123b --- /dev/null +++ b/resources/tester/helper_472_5r_rock.mjs @@ -0,0 +1,121 @@ +/* 472-5 남음 헬퍼 — node helper_472_5r_rock.mjs <묶음.mjs> · 표준출력 JSON. + * ① 암 경계(▲▼ · 되돌림) · 암 절토 경사를 고침 → ↶ → 다른 측점을 고침 → ↶ ↷ — 세션 칸 · 카드 읽기 · 저장 재료가 + * 되돌린 값을 따르는가(옛 코드 = 메모리 맵이 옛 값을 쥐고 다음 조작이 칸을 통째로 다시 씀 · N5). + * ② clearRouteDrafts — 그 노선 번호의 노선 범위 초안만 비움(다른 노선 · 프로젝트 칸 그대로). */ +import { pathToFileURL } from "node:url"; + +const storage = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.window = { sessionStorage: storage(), localStorage: storage() }; // 손길 듣기 없음 = 열 때 규칙 꺼짐 +globalThis.sessionStorage = window.sessionStorage; +globalThis.localStorage = window.localStorage; +globalThis.document = any; + +const m = await import(pathToFileURL(process.argv[2]).href); +const P = "p4725rock"; +const R = 7; +localStorage.setItem("frd_current_project_id", P); +const SAVED_ROCK = -0.8; // 저장분 암 경계(측점 100) +const sections = [100, 120, 140].map((chainage_m) => ({ + chainage_m, + station_id: `s${chainage_m}`, + design: chainage_m === 100 ? { rock_boundary_offset_m: SAVED_ROCK } : {}, +})); +const detail = { cross_sections: sections }; +const refreshed = []; +const rock = m.createRockBoundaryStore({ + sessionKey: () => m.stateKey("rockb", P, R), + detail: () => detail, + refreshCard: (at) => refreshed.push(at), + recompute: () => undefined, +}); +rock.setDefaults(-0.5, 0.1); +const cut = m.createCutSlopeStore({ + sessionKey: () => m.stateKey("cutslope", P, R), + standard: () => ({ rock: { cut_slope_ratio: 0.4 }, soil: { cut_slope_ratio: 1.0 } }), + refreshCard: () => undefined, + recompute: () => undefined, +}); +const cell = (name) => { + const raw = m.readState(name, P, R) ?? {}; + // 측점 열쇠 칸 = 열쇠 → 값 · `@at`(쓴 때 누가거리) · `@seat` — 누가거리 → 값으로 풀어 봄 + const at = raw["@at"] ?? {}; + return Object.fromEntries( + Object.entries(raw) + .filter(([key]) => !key.startsWith("@")) + .map(([key, value]) => [Number(at[key] ?? key).toFixed(2), value]), + ); +}; +const cells = () => ({ rockb: cell("rockb"), cutslope: cell("cutslope") }); +const reads = () => + Object.fromEntries( + sections.map((s) => [ + s.chainage_m, + { rock: rock.control.offsetFor(s), cut: cut.control.ratioFor(s) }, + ]), + ); +const out = {}; + +const history = m.createProfileHistory(() => undefined, 100); +// 조작 A — 100 ▼(저장분 −0.8 → −0.9) · 120 ▲(기본 −0.5 → −0.4) · 140 암 경사 0.7 +rock.control.adjust(100, -0.1); +rock.control.adjust(120, 0.1); +cut.control.set(140, 0.7); +history.record(); +out.afterA = cells(); +out.readsA = reads(); + +history.undo(); // ↶ = 세션 칸을 조작 전으로 되씀(저장소를 안 거침) +out.afterUndo = cells(); +out.readsUndo = reads(); + +// 조작 B — 다른 측점(옛 코드 = 메모리 맵이 A 값을 쥐고 통째로 다시 씀) +rock.control.adjust(140, -0.1); +cut.control.set(100, 0.5); +out.afterB = cells(); +out.payloadB = { + rock: Object.fromEntries(typeof rock.offsets === "function" ? rock.offsets() : rock.offsets), // 옛 꼴 = Map + cut: Object.fromEntries(typeof cut.ratios === "function" ? cut.ratios() : cut.ratios), +}; +history.record(); +history.undo(); +out.afterUndo2 = cells(); +history.redo(); // ↷ = B 그대로 +out.afterRedo = cells(); +out.readsRedo = reads(); +// 되돌림(↺) — 암 경계 = 기본값 · 암 경사 = 0(표준 표시) +rock.control.reset(140); +cut.control.set(100, null); +out.afterReset = cells(); +out.readsReset = reads(); + +// ② 노선 저장 뒤 옛 노선 칸만 비움 +m.writeState("rockb", { "grid:1": -0.7 }, P, 8); +m.writeState("crossw", { "100.00": 6 }, P, R); +m.writeState("uphill", { "100.000": "left" }, P); +m.writeState("rockb", { "grid:1": -0.6 }, "other", R); +const keys = () => + Array.from({ length: sessionStorage.length }, (_, i) => sessionStorage.key(i)) + .filter((key) => key.startsWith("aislo:draft:")) + .sort(); +out.keysBefore = keys(); +m.clearRouteDrafts(P, R); +out.keysAfter = keys(); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_472_5r_rock_entry.ts b/resources/tester/helper_472_5r_rock_entry.ts new file mode 100644 index 000000000..433470387 --- /dev/null +++ b/resources/tester/helper_472_5r_rock_entry.ts @@ -0,0 +1,7 @@ +/* 472-5 남음 헬퍼 — 암 경계 · 암 경사 값 주인 = 세션 칸(vite ssr) · test_472_5r_rock_owner.py 가 부름. */ +export { createProfileHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History"; +export { + createCutSlopeStore, + createRockBoundaryStore, +} from "../../B06_Section/B06_Section_UI_Page_Persist"; +export { clearRouteDrafts, readState, stateKey, writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/helper_472_5r_route_save.cjs b/resources/tester/helper_472_5r_route_save.cjs new file mode 100644 index 000000000..bf15cbdb4 --- /dev/null +++ b/resources/tester/helper_472_5r_route_save.cjs @@ -0,0 +1,68 @@ +/* 472-5 남음 헬퍼 — 노선 바뀐 [저장] 뒤 옛 노선 세션 칸 · 초안 전 hold 비움(`design_save.ts` 그대로 · 서버 창구 · + * 세션 창구는 가짜). 짝 = `test_472_5r_rock_owner.py`. 표준출력 JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +globalThis.window = { setTimeout, clearTimeout }; + +const anything = () => + new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) }); + +function load(file, stubs) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r] ?? anything()); + return box.exports; +} + +async function once(newRouteId, fail) { + const calls = []; + const KEYS = ["settings", "pipes", "structures", "plusStations", "alignment"]; + const save = load(path.join(ROOT, "A00_Common", "design", "design_save.ts"), { + "../b_page_state": { + clearRouteDrafts: (p, r) => calls.push(`clearRouteDrafts:${p}:${r}`), + clearState: (name, p) => calls.push(`clearState:${name}:${p}`), + stateKey: () => null, + }, + "./design_save_api": { + SAVE_PART_KEYS: [...KEYS, "cross"], + DesignSaveError: Error, + postDesignSave: async () => { + calls.push("post"); + if (fail) throw new Error("서버 500"); + return { waited: false, steps: { route: { route_id: newRouteId } } }; + }, + }, + "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": { + pendingRouteSave: async () => ({ draft: 1 }), + routeCarryPart: () => ({ carry: {}, cross: { body: {} } }), + routeSaved: () => calls.push("routeSaved"), + routeSaveSummary: () => "", + rebuildAfterRouteSave: async () => calls.push("rebuild"), + }, + }); + const done = await save.saveDesign({ + projectId: "p1", + routeId: () => 3, + crossPart: async () => null, + failed: () => calls.push("failed"), + }); + // 저장 뒤 정리와 무관한 칸(종류 캐시)은 빼고 봄 + return { done, calls: calls.filter((c) => !c.includes("structure-type-cache")) }; +} + +(async () => { + const out = { + moved: await once(9, false), + same: await once(3, false), + failed: await once(9, true), + }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error.stack || error)); + process.exit(1); +}); diff --git a/resources/tester/test_176_3_no_uphill_lamp.py b/resources/tester/test_176_3_no_uphill_lamp.py index bbeb01c64..817fb8b44 100644 --- a/resources/tester/test_176_3_no_uphill_lamp.py +++ b/resources/tester/test_176_3_no_uphill_lamp.py @@ -28,10 +28,11 @@ def test_page_has_no_lamp_handler(): def test_save_path_and_merge_kept(): # 저장 길(세션 → 저장 몸통)과 B06 이 부르는 종단 병합 함수는 그대로 + # 값 주인 = 세션 칸 `uphill` 하나 — 종단 보기 메모리 사본(uphillOverrides) 걷음(472-5) page = _read("A00_Common", "design", "design_view_profile.ts") - assert "persistUphillOverrides" in page - # 상단측 본문 = 기본 조각 한 벌(463 · 472-6 종단 보기 몫은 그 본문 그대로) - assert "body: base.body," in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts") + assert "uphillOverrides" not in page + # 상단측 본문 = 기본 조각 한 벌(463 · 472-6) — 종단 보기 몫 없음(사본 걷음 · 472-5) + assert "uphill:" not in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts") assert "body: await routePartsBody(projectId)," in _read( "A00_Common", "design", "design_save.ts" ) diff --git a/resources/tester/test_271_save_same.py b/resources/tester/test_271_save_same.py index 1ad16624d..a6d4a2922 100644 --- a/resources/tester/test_271_save_same.py +++ b/resources/tester/test_271_save_same.py @@ -62,7 +62,8 @@ def test_종단_보기를_열든_안_열든_같은_요청(got): } # 상단측 세션 · 종단 보기 메모리 둘 다 비움(다음 [저장]이 옛 값을 다시 안 보냄) assert got["closed"]["uphillAfter"] is None and got["open"]["uphillAfter"] is None - assert got["openMemoryAfter"] == 0 + # 종단 보기 몫에 상단측 사본(메모리 맵) 없음 — 기본 조각 그대로(472-5) + assert got["openOwnUphill"] == "undefined" def test_노선_캐시_없어도_같은_본문(got): @@ -80,9 +81,9 @@ def test_서버는_그_본문이면_비정규_측점_횡단을_맞춤(got): def test_종단_보기_몫은_기본_창구_함수를_부름(): actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8") save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8") - # 472-6 — 종단 보기 몫은 기본 조각 본문 그대로(메모리만 더 비움) · 노선 바뀐 저장도 한 본문(6-B · - # 옛 창구 `flushRouteParts` 걷음 · 노선 바뀌면 손 상단측 안 보냄 = 요구 274) - assert "uphill: (base) =>" in actions and "body: base.body," in actions + # 472-6 — 상단측 = 기본 조각 한 벌(종단 보기 몫 없음 · 메모리 사본 걷음 · 472-5) · 노선 바뀐 저장도 한 + # 본문(6-B · 옛 창구 `flushRouteParts` 걷음 · 노선 바뀌면 손 상단측 안 보냄 = 요구 274) + assert "uphill:" not in actions assert "saveRouteParts(" not in actions and "flushRouteParts" not in actions assert "body: await routePartsBody(projectId)," in save assert "flushRouteParts" not in save and "if (moved) return null;" in save diff --git a/resources/tester/test_443_save_stops.py b/resources/tester/test_443_save_stops.py index 67b9aee98..409ab34bd 100644 --- a/resources/tester/test_443_save_stops.py +++ b/resources/tester/test_443_save_stops.py @@ -81,8 +81,8 @@ def test_확정_하위_실패면_던짐(out): def test_종단_보기_상단측_차례도_같은_창구(): actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8") - # 기본 창구와 같은 본문(463 · 472-6 기본 조각) — 상단측은 세션에서 읽음(메모리 옛 값 안 보냄) - assert "body: base.body," in actions and "flushRouteParts" not in actions + # 기본 조각 한 벌(463 · 472-6 · 종단 보기 몫 없음 · 472-5) — 상단측은 세션에서 읽음(메모리 사본 없음) + assert "uphill:" not in actions and "flushRouteParts" not in actions fetch = (ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts").read_text("utf-8") assert "uphill_overrides: uphillOverridesOf(projectId)," in fetch assert "confirmRoute(" not in actions diff --git a/resources/tester/test_472_5r_rock_owner.py b/resources/tester/test_472_5r_rock_owner.py new file mode 100644 index 000000000..26c57b416 --- /dev/null +++ b/resources/tester/test_472_5r_rock_owner.py @@ -0,0 +1,142 @@ +"""PLAN 472-5 남음 (요구 272 · 273) — 암 경계 · 암 절토 경사 값 주인 = 세션 칸 하나 · 메모리 맵 걷음(N5) · +노선 저장 뒤 옛 노선 칸만 비움. + +못박는 것 + - 암 경계(▲▼ · ↺) · 암 경사를 고친 뒤 ↶ 하고 다른 측점을 고쳐도 되돌린 값이 되살아나지 않음 — 옛 코드는 메모리 + 맵(`offsets` · `ratios`)이 옛 값을 쥐고 다음 조작이 칸을 통째로 다시 써 ↶ 를 덮었음(옛 코드 실패). + - ↶ 뒤 카드 읽기 = 조작 전 값(저장분 그대로) · ↷ = 다시 그 값 · 저장 재료(`offsets()` · `ratios()`)도 세션 칸 그대로. + - `clearRouteDrafts` = 그 노선 번호의 노선 범위 초안만 비움(다른 노선 · 다른 프로젝트 · 프로젝트 칸 그대로). + - 노선 바뀐 [저장] 성공 뒤 옛 노선 칸 · 초안 전 hold 비움 · 실패면 그대로(`design_save.ts`). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_5r_rock_owner.py -q +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +P = "p4725rock" + + +@pytest.fixture(scope="module") +def out(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("s4725r") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_472_5r_rock_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + check=True, + capture_output=True, + timeout=300, + ) + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_472_5r_rock_entry.js", bundle) + proc = subprocess.run( # noqa: S603 + ["node", str(HERE / "helper_472_5r_rock.mjs"), str(bundle)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_조작_A_는_세션_칸에(out) -> None: + a = out["afterA"] + assert a["rockb"] == {"100.00": -0.9, "120.00": -0.4} + assert a["cutslope"] == {"140.00": 0.7} + r = out["readsA"] + assert r["100"]["rock"] == -0.9 and r["120"]["rock"] == -0.4 and r["140"]["cut"] == 0.7 + + +def test_되돌리면_칸이_비고_카드도_조작_전(out) -> None: + assert out["afterUndo"] == {"rockb": {}, "cutslope": {}} + r = out["readsUndo"] + assert r["100"] == {"rock": -0.8, "cut": 0.4} # 저장분 그대로 + assert r["120"] == {"rock": -0.5, "cut": 0.4} + assert r["140"] == {"rock": -0.5, "cut": 0.4} + + +def test_되돌린_뒤_다른_조작이_되돌림을_안_덮음(out) -> None: + """N5 — 옛 코드는 메모리 맵이 A 값을 쥐고 B 를 쓸 때 통째로 다시 씀(옛 코드 실패).""" + assert out["afterB"] == {"rockb": {"140.00": -0.6}, "cutslope": {"100.00": 0.5}} + assert out["payloadB"] == {"rock": {"140.00": -0.6}, "cut": {"100.00": 0.5}} + + +def test_다시하기_뒤_값_같음(out) -> None: + assert out["afterUndo2"] == {"rockb": {}, "cutslope": {}} + assert out["afterRedo"] == out["afterB"] + r = out["readsRedo"] + assert r["100"] == {"rock": -0.8, "cut": 0.5} + assert r["140"] == {"rock": -0.6, "cut": 0.4} + + +def test_되돌림_단추_기본값_표준(out) -> None: + assert out["afterReset"] == {"rockb": {"140.00": -0.5}, "cutslope": {"100.00": 0}} + r = out["readsReset"] + assert r["140"]["rock"] == -0.5 and r["100"]["cut"] == 0.4 + + +def test_옛_노선_칸만_비움(out) -> None: + before, after = set(out["keysBefore"]), set(out["keysAfter"]) + gone = before - after + assert gone == { + f"aislo:draft:rockb:{P}:7", + f"aislo:draft:cutslope:{P}:7", + f"aislo:draft:crossw:{P}:7", + } + assert f"aislo:draft:rockb:{P}:8" in after + assert "aislo:draft:rockb:other:7" in after + assert f"aislo:draft:uphill:{P}" in after + + +def test_노선_저장_뒤_옛_노선_칸_비움(tmp_path) -> None: + proc = subprocess.run( # noqa: S603 + ["node", str(HERE / "helper_472_5r_route_save.cjs")], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + got = json.loads(proc.stdout) + # 새 노선 번호(9) ≠ 옛(3) — 다 저장하고 페이지를 다시 세운 뒤 옛 노선 칸 · hold 비움 + assert got["moved"]["done"] is True + assert got["moved"]["calls"][-3:] == [ + "rebuild", + "clearRouteDrafts:p1:3", + "clearState:route-draft-hold:p1", + ] + # 같은 번호면 노선 칸은 안 지움(새 노선 칸일 수 있음) · hold 만 + assert "clearRouteDrafts:p1:3" not in got["same"]["calls"] + assert got["same"]["calls"][-1] == "clearState:route-draft-hold:p1" + # 서버 실패 = 초안 그대로 + assert got["failed"]["done"] is False + assert not [c for c in got["failed"]["calls"] if c.startswith("clear")]