From 8ebb8258f9efc5f13263a380da907d85e27b98b6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 16:41:03 +0900 Subject: [PATCH] =?UTF-8?q?fix(472=20=EB=8D=A7=20B06=20=ED=97=9B=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=C2=B7=20=EC=A7=80=EB=B0=98):=20B06=20?= =?UTF-8?q?=EC=97=B4=EA=B8=B0=EB=A7=8C=20=ED=95=B4=EB=8F=84=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=ED=91=9C=EC=A4=80=20=ED=9A=A1=EB=8B=A8=20=EC=B4=88?= =?UTF-8?q?=EC=95=88(std-cross)=EC=9D=B4=20=EC=83=9D=EA=B8=B0=EB=8D=98=20?= =?UTF-8?q?=ED=9D=A0=20=E2=80=94=20=ED=8C=A8=EB=84=90=EC=9D=B4=20=EC=97=B4?= =?UTF-8?q?=20=EB=95=8C=20config=20=EB=A5=BC=20=C2=B7=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EA=B0=92=20=EB=B0=9B=EA=B8=B0=EA=B0=80=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=B6=84=EC=9D=84=20=EB=B2=A0=EA=BB=B4=20=EC=94=80=20=E2=86=92?= =?UTF-8?q?=20=EC=95=88=20=EC=94=80=20=C2=B7=20=EA=B3=84=EC=82=B0=20=3D=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=E2=86=92=20=EC=A0=80=EC=9E=A5=EB=B6=84(se?= =?UTF-8?q?ction-context)=20=E2=86=92=20config=20=C2=B7=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EA=B0=92=EC=9D=B4=EB=A9=B4=20=EC=B9=B8=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=9D=BC=EA=B3=B1=20=EC=B9=B8=20=ED=86=B5=EC=A7=B8?= =?UTF-8?q?=EB=A1=9C=20=EC=8B=A4=EC=96=B4=20=EC=95=88=20=EB=B0=94=EA=BE=BC?= =?UTF-8?q?=20=EC=A7=80=EB=B0=98(ripping=5Frock)=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EB=A9=B4=20=EC=9C=A0=ED=98=95=EC=9D=84=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=84=A0=ED=83=9D=EC=9C=BC=EB=A1=9C=20=EB=B0=95?= =?UTF-8?q?=EB=8D=98=20=ED=9D=A0=20=E2=86=92=20=EB=B0=94=EA=BE=BC=20?= =?UTF-8?q?=EC=B9=B8=EB=A7=8C(=EC=95=9E=EC=84=9C=20=EB=B0=94=EA=BE=BC=20?= =?UTF-8?q?=EC=B9=B8=EC=97=90=20=EC=96=B9=EC=9D=8C=20=C2=B7=20[=EC=A0=80?= =?UTF-8?q?=EC=9E=A5]=20patch=20=C2=B7=20=EB=85=B8=EC=84=A0=20=EB=84=98?= =?UTF-8?q?=EA=B9=80=EB=8F=84=20=EB=B9=88=20=EC=B9=B8=20=EC=95=88=20?= =?UTF-8?q?=EC=8B=A4=EC=9D=8C)=20=C2=B7=20=EC=86=8C=EB=8B=A8=20=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=20berm=20=3D=20=EB=96=A0=EB=82=A8=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=96=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=E3=80=8C?= =?UTF-8?q?=ED=86=A0=EC=82=AC=E3=80=8D=20=3D=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=EA=BA=BC=EC=A7=90=20=3D=20=EC=83=81=EC=84=B8=20ripping=5Frock(?= =?UTF-8?q?=EA=B0=92=20=EA=B0=99=EC=9D=8C)=20=C2=B7=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=20test=5F472=5Fopen=5Fno=5Fdraft(=EC=98=9B=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B9=A8=EA=B0=95=209)=20=C2=B7=20=EC=98=9B=20?= =?UTF-8?q?=EC=94=A8=EC=95=97=20=EB=8B=A8=EC=96=B8=20=EC=85=8B=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8(364=5F6=20=C2=B7=20stored=5Fstandard=20=C2=B7=20user?= =?UTF-8?q?=5Fvalues)=20=C2=B7=20=EC=9C=84=ED=82=A4=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=20=EC=B9=B8=20=C2=B7=20=EA=B7=9C=EC=B9=99=20=C2=B7=20B06=20?= =?UTF-8?q?=EC=A1=B0=EC=9E=91=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_leave.ts | 3 +- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 9 +- B06_Section/B06_Section_Api_Context.ts | 36 +--- .../B06_Section_Cross_Design_Session.ts | 15 +- B06_Section/B06_Section_Cross_Refresh.ts | 4 +- B06_Section/B06_Section_Route_Carry_Patch.ts | 8 +- B06_Section/B06_Section_UI_Cross_Design.ts | 39 ++-- .../B06_Section_UI_Page_Design_Sync.ts | 27 ++- B06_Section/B06_Section_UI_Page_Persist.ts | 4 +- B06_Section/B06_Section_UI_Standard_Panel.ts | 43 +++- .../concepts/design_page/operations_b06.md | 4 +- docs/wiki/concepts/design_page/rules.md | 1 + .../wiki/concepts/design_page/session_keys.md | 6 +- resources/tester/helper_472_open_no_draft.mjs | 195 ++++++++++++++++++ .../tester/helper_472_open_no_draft_entry.ts | 15 ++ .../tester/test_364_6_std_panel_same_value.py | 3 +- resources/tester/test_472_open_no_draft.py | 109 ++++++++++ ...est_b06_stored_standard_reaches_browser.py | 23 ++- resources/tester/test_user_values_survive.py | 9 +- 19 files changed, 460 insertions(+), 93 deletions(-) create mode 100644 resources/tester/helper_472_open_no_draft.mjs create mode 100644 resources/tester/helper_472_open_no_draft_entry.ts create mode 100644 resources/tester/test_472_open_no_draft.py diff --git a/A00_Common/design/design_leave.ts b/A00_Common/design/design_leave.ts index ddb73bd98..3c9a534b8 100644 --- a/A00_Common/design/design_leave.ts +++ b/A00_Common/design/design_leave.ts @@ -3,7 +3,7 @@ * B05 · B06 떠남 확인(346-4) — 저장 안 한 조작(초안 통)이 있으면 탭 닫기 · 다른 페이지로 갈 때 * 공용 물음(`ui_template_leave_dialog` · [취소] [폐기] [저장]). * - * 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김) · + * 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김 · 소단 사본 = 구조물 파생) · * 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것. * 횡단 초안은 [저장] 뒤에도 세션에 남음 — 그래서 「있음」이 아니라 **마지막 깨끗한 때와 다름**으로 봄: * [저장] · [확정]이 끝나면 `markDesignClean` 이 그때 지문을 적음 · [초기화]는 초안을 다 지우고 새로 읽음. @@ -31,6 +31,7 @@ const NOT_EDITS = new Set([ "structure-pick", "structure-type-cache", "std-cross", + "berm", // 소단 사본 — 열 때 시설 줄이 구조물에서 씀(272 · 고침은 `structures` 칸) ]); const OTHER_PAGE = /^b0[89]-/; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 6bcaaa925..94fbb43b4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -23,7 +23,10 @@ import { sectionConfirmBody, type StandardCrossSection, } from "../B06_Section/B06_Section_Api_Fetch"; -import { readStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel"; +import { + readStandardCrossSession, + readStoredStandard, +} from "../B06_Section/B06_Section_UI_Standard_Panel"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import type { SavePart } from "../A00_Common/design/design_save_api"; import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch"; @@ -161,7 +164,9 @@ export function routeCarryPart( projectId: string, fromRouteId: number, ): { carry: Record; cross: SavePart } { - const standard = readStandardCrossSession(projectId) as StandardCrossSection | null; + // 세션 고침 → 저장분(세션에 안 베낌 · 272) — 서버 새 측점 기본 설계가 저장분 표준으로 섬 + const standard = (readStandardCrossSession(projectId) ?? + readStoredStandard(projectId)) as StandardCrossSection | null; const uphill = Object.keys(readState("uphill", projectId) ?? {}).length; return { carry: routeCarryInputs(projectId, fromRouteId), diff --git a/B06_Section/B06_Section_Api_Context.ts b/B06_Section/B06_Section_Api_Context.ts index 03b74160a..4a4beed9c 100644 --- a/B06_Section/B06_Section_Api_Context.ts +++ b/B06_Section/B06_Section_Api_Context.ts @@ -3,23 +3,24 @@ * 종횡단 설정값(`GET …/sections/context`)의 **세션 캐시 창구** — 화면 쪽. * * `B06_Section_Api_Fetch.ts` 에서 갈라냄(472-2) — 서버 Node 묶음이 Api_Fetch 를 끌어도 - * 세션(`b_page_state`) · 되돌리기 사진첩(`Profile_History`)을 함께 끌지 않게. + * 세션(`b_page_state`)을 함께 끌지 않게. * ========================================================================== */ -import { clearState, readState, stateKey, writeState } from "../A00_Common/b_page_state"; -import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { requestSectionContext } from "./B06_Section_Api_Fetch"; import type { SectionContextResponse } from "./B06_Section_Api_Types"; /** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */ /** 종횡단 설정값 — ④ 계산 결과라 세션에 담아 두고 화면을 오갈 때마다 다시 묻지 않는다 - * (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다. */ + * (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다. + * 저장된 표준 횡단면(`stored_standard_cross_section`)은 이 칸에서 바로 읽음(`readStoredStandard`) — + * 초안 칸(`std-cross`)에 베끼지 않음(272 · 값이 저장분과 같으면 초안 칸 없음). */ export async function fetchSectionContext(projectId: string): Promise { const cached = readState("section-context", projectId); - if (cached) return seedStandardCross(projectId, cached); + if (cached) return cached; const fresh = await requestSectionContext(projectId); writeState("section-context", fresh, projectId); - return seedStandardCross(projectId, fresh); + return fresh; } /** 서버가 지금 보는 노선 id — 세션 캐시를 건너뛰고 묻는다. [저장] · [확정] 앞에서 화면이 쥔 @@ -30,29 +31,6 @@ export async function fetchCurrentRouteId(projectId: string): Promise("std-cross", projectId) === null) { - writeState("std-cross", stored, projectId); - seedPageHistory(stateKey("std-cross", projectId)); // 씨앗 — 되돌리기 걸음 아님(364-1) - } - return context; -} - export function clearSectionContextCache(projectId: string): void { clearState("section-context", projectId); } diff --git a/B06_Section/B06_Section_Cross_Design_Session.ts b/B06_Section/B06_Section_Cross_Design_Session.ts index 8ba435be4..7c65885b9 100644 --- a/B06_Section/B06_Section_Cross_Design_Session.ts +++ b/B06_Section/B06_Section_Cross_Design_Session.ts @@ -14,10 +14,10 @@ import { chainageKey } from "@util/common_util_station_key"; import { readKeyed, writeKeyed } from "./B06_Section_Station_Keys"; -/** 카드에서 고를 수 있는 값 한 벌 — 서버 `CrossDesignRequest` 와 같은 이름을 쓴다. */ +/** 카드에서 고를 수 있는 값 — 서버 `CrossDesignRequest` 와 같은 이름 · **사용자가 바꾼 칸만**(272). */ export interface CrossDesignChoice { - ground_type: string; - section_mode: string; + ground_type?: string; + section_mode?: string; ditch_side?: string | null; ditch_type?: string | null; paved?: boolean; @@ -42,6 +42,15 @@ export function writeCrossDesignChoice( writeKeyed("crossdesign", projectId, routeId, new Map([[at, choice]]), [at]); } +/** 한 측점의 지금 선택값(없으면 undefined) — 바꾼 칸을 얹을 바탕(272). */ +export function readCrossDesignChoice( + projectId: string, + routeId: number, + chainageM: number, +): CrossDesignChoice | undefined { + return readAll(projectId, routeId).get(chainageKey(chainageM)) ?? undefined; +} + /** [저장]·[확정]이 patch 로 실을 목록 — 누가거리(숫자) → 선택값. */ export function crossDesignChoices( projectId: string | null, diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 1d5887eec..e7c0b586d 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -54,6 +54,7 @@ import { effectiveStandardCross, readRockBoundaryDefault, readStandardCrossSession, + readStoredStandard, } from "./B06_Section_UI_Standard_Panel"; import { buildAlignment, @@ -501,7 +502,8 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { projectId, routeId, edits, - readStandardCrossSession(projectId) ?? undefined, + // 세션 고침 → 저장분(세션에 안 베낌 · 272) — 둘 다 없으면 서버 config + readStandardCrossSession(projectId) ?? readStoredStandard(projectId) ?? undefined, { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), diff --git a/B06_Section/B06_Section_Route_Carry_Patch.ts b/B06_Section/B06_Section_Route_Carry_Patch.ts index 7b6947a06..643eeffa0 100644 --- a/B06_Section/B06_Section_Route_Carry_Patch.ts +++ b/B06_Section/B06_Section_Route_Carry_Patch.ts @@ -411,11 +411,9 @@ export function routeCarryPatches(input: RouteCarryInput): { // 세션에만 있는 고침(카드 선택 · 암선 · 암 경사) — 옛 `sessionOverlay` 와 같은 칸(445 H5) const overlay: CrossSectionPatch[] = []; byChainage(read("crossdesign")).forEach((choice, chainage) => { - const patch: Record = { - chainage_m: chainage, - ground_type: choice.ground_type, - section_mode: choice.section_mode, - }; + const patch: Record = { chainage_m: chainage }; + if (choice.ground_type !== undefined) patch.ground_type = choice.ground_type; + if (choice.section_mode !== undefined) patch.section_mode = choice.section_mode; if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side; if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type; if (choice.paved !== undefined) patch.paved = choice.paved; diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 1a521fa4e..4876cd1a1 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -104,17 +104,18 @@ const DITCH_TYPE_OPTIONS: Array<[DitchType, keyof typeof ui_locales]> = [ ["l_type", "B06_Design_DitchType_LType"], ]; +/** 카드 고름 — **바꾼 칸만** 실림(272 · 안 바꾼 지반 · 단면 유형을 사용자 선택으로 박지 않음). */ export interface CrossDesignChange { - ground_type: GroundType; - section_mode: SectionMode; - ditch_side: DitchSide | null; - ditch_type: DitchType; - paved: boolean; + ground_type?: GroundType; + section_mode?: SectionMode; + ditch_side?: DitchSide | null; + ditch_type?: DitchType; + paved?: boolean; /** 암 지반 2단계 경사(암반 경계 아래=암, 위=토사) 적용 여부. 기본 true, 토글로 해제. */ - two_stage_slope: boolean; + two_stage_slope?: boolean; /** 측구를 둘지 **사용자가 정한 선택**. `null` = 자동 판정. * ⚠ 결과(`design.ditch_enabled` = 실제 섰나)와 **다른 값**이다(2026-09-09 정리). */ - ditch_choice: boolean | null; + ditch_choice?: boolean | null; /** 설계선 쉬프트(139-6) — 있으면 그 쪽 노견 확장만 바꾸는 변경(다른 칸은 안 씀). */ shoulder_extend?: { side: "left" | "right"; valueM: number }; /** 소단 높이(194-1) — 있으면 그 쪽 소단 깊이(노면 끝 아래 m)만 바꾸는 변경. */ @@ -274,19 +275,25 @@ export function buildDesignControls( // 경사 방향 선택이 필요한 경우 = 양절(both_cut)·양성(both_fill). 편절은 절토측 자동. // 양성은 측구가 없어도 노면 기울기 방향(생략된 측구 방향)을 여기서 지정한다(N-2-3). const needsDitch = (): boolean => state.mode === "both_cut" || state.mode === "both_fill"; + const choiceOf = (): Required> => ({ + ground_type: state.ground, + section_mode: state.mode, + ditch_side: needsDitch() ? (state.ditch ?? "left") : null, + ditch_type: state.ditchType, + paved: state.paved, + two_stage_slope: state.twoStage, + ditch_choice: state.ditchEnabled, + }); + const opened = choiceOf(); // 카드가 선 때 — 이와 다른 칸만 보냄(272) const emit = (): void => { if (!state.ground || !state.mode) return; // L형 측구는 암 전용 — 토사로 되돌리면 일반측구로 강등해 서버 거부를 예방한다. if (!isRock(state.ground)) state.ditchType = "standard"; - onChange(section.chainage_m, { - ground_type: state.ground, - section_mode: state.mode, - ditch_side: needsDitch() ? (state.ditch ?? "left") : null, - ditch_type: state.ditchType, - paved: state.paved, - two_stage_slope: state.twoStage, - ditch_choice: state.ditchEnabled, - }); + const now = choiceOf(); + const changed = Object.fromEntries( + Object.entries(now).filter(([key, value]) => opened[key as keyof typeof opened] !== value), + ) as CrossDesignChange; + if (Object.keys(changed).length) onChange(section.chainage_m, changed); }; // 지반유형: 제목행 배치용으로 분리 반환(D-6). 「토사」 토글 하나 — 끄면 암이다(2026-09-07). diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index fe9c1f655..4a9331e3a 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -11,9 +11,11 @@ * ========================================================================== */ import { + readCrossDesignChoice, writeBermDepth, writeCrossDesignChoice, writeShoulderExtend, + type CrossDesignChoice, } from "./B06_Section_Cross_Design_Session"; import { showToast } from "@ui/ui_template_elements"; import { L } from "./B06_Section_UI_Page_Common"; @@ -41,6 +43,17 @@ export interface DesignSyncController { applyPanelToAll: () => Promise; } +/** 카드 고름 칸 — 세션 `crossdesign` 에 담는 것(바꾼 칸만 · 272). */ +const CHOICE_KEYS = [ + "ground_type", + "section_mode", + "ditch_side", + "ditch_type", + "paved", + "two_stage_slope", + "ditch_choice", +] as const satisfies ReadonlyArray; + export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { const { projectId } = ctx; // 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세 · 칸 크기 그림(173-4) @@ -85,14 +98,14 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { // 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고, // [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로 // 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다. + // 바꾼 칸만(272) — 같은 측점 앞서 바꾼 칸에 얹음 + const picked = Object.fromEntries( + CHOICE_KEYS.filter((key) => change[key] !== undefined).map((key) => [key, change[key]]), + ) as CrossDesignChoice; + if (!Object.keys(picked).length) return; writeCrossDesignChoice(projectId, currentRouteId, chainageM, { - ground_type: change.ground_type, - section_mode: change.section_mode, - ditch_side: change.ditch_side ?? null, - ditch_type: change.ditch_type, - paved: change.paved, - two_stage_slope: change.two_stage_slope, - ditch_choice: change.ditch_choice, + ...readCrossDesignChoice(projectId, currentRouteId, chainageM), + ...picked, }); // (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다. await reconcileStaleDesigns({ force: true }); diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 50b7a048c..f14377a40 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -324,8 +324,8 @@ export function collectSectionEdits(ctx: SectionPersistContext): { // 유일한 길이다(2026-09-06 사용자 확정: 버튼을 누를 때 서버가 저장하지 않는다). crossDesignChoices(ctx.projectId, ctx.routeId()).forEach((choice, chainage) => { const patch = patchFor(chainage); - patch.ground_type = choice.ground_type; - patch.section_mode = choice.section_mode; + if (choice.ground_type !== undefined) patch.ground_type = choice.ground_type; + if (choice.section_mode !== undefined) patch.section_mode = choice.section_mode; if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side; if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type; if (choice.paved !== undefined) patch.paved = choice.paved; diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index 00b7d601e..11a5f15eb 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -21,11 +21,10 @@ import { clearState, readState, readStateRaw, - stateKey, writeState, writeStateRaw, } from "../A00_Common/b_page_state"; -import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History"; +import type { SectionContextResponse } from "./B06_Section_Api_Types"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; @@ -75,11 +74,26 @@ export function readRockBoundaryDefault(projectId: string): number | null { } /** - * 횡단 계산에 넣을 표준단면 한 벌 — **세션 편집값이 있으면 그쪽, 없으면 config 기본값**. + * 이 프로젝트에 **저장된** 표준 횡단면(`sections/context` 의 `stored_standard_cross_section`) — + * 세션 칸에 베껴 두지 않고 결과 칸(`section-context`)에서 바로 읽음(272 · 값이 저장분과 같으면 초안 칸 없음). + */ +export function readStoredStandard(projectId: string): StandardCrossSection | null { + return ( + readState("section-context", projectId) + ?.stored_standard_cross_section ?? null + ); +} + +/** + * 횡단 계산에 넣을 표준단면 한 벌 — **세션 편집값 → 저장분 → config 기본값**. * 서버 `_resolve_group(preset, standard)`의 "요청값 → config" 우선순위와 같은 뜻이다. */ export function effectiveStandardCross(projectId: string): StandardCrossSection | null { - return readStandardCrossSession(projectId) ?? readStandardDefaults(projectId); + return ( + readStandardCrossSession(projectId) ?? + readStoredStandard(projectId) ?? + readStandardDefaults(projectId) + ); } /** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */ @@ -284,10 +298,17 @@ export function createStandardPanel( ): StandardPanelController { // 브라우저 횡단 계산이 패널 없이도 config 기본값을 쓸 수 있게 먼저 기억해 둔다. rememberStandardDefaults(projectId, defaults); - // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. - const sessionValue = readStandardCrossSession(projectId); + // 세션값 우선, 없으면 저장분 → config 기본값. defaults는 복원 기준으로 보존한다. + // 세션 칸 = 저장분(없으면 config)과 다를 때만 — 같으면 칸 없음(272 · 열기만 해서 초안이 생기지 않게). + const baseline = (): string => JSON.stringify(readStoredStandard(projectId) ?? defaults); + let sessionValue = readStandardCrossSession(projectId); + if (sessionValue && JSON.stringify(sessionValue) === baseline()) { + clearStandardCrossSession(projectId); // 옛 판이 열 때 베껴 둔 같은 값 + sessionValue = null; + } const hadSession = sessionValue !== null; - const state: StandardCrossSection = sessionValue ?? cloneDefaults(defaults); + const state: StandardCrossSection = + sessionValue ?? cloneDefaults(readStoredStandard(projectId) ?? defaults); const root = document.createElement("div"); root.className = "b06-std"; @@ -305,7 +326,7 @@ export function createStandardPanel( // (`handleDesignChange` 가 패널 값을 실어 보냄). 알려 주지 않으면 한 화면에 옛 값과 새 값이 // 섞여 서고, 사용자는 왜 그 측점만 값이 달라졌는지 알 길이 없다. // ⓘ 실측 예 — 측점 20.0m 절토 계 3.33㎡(저장분) ↔ 3.90㎡(패널 초안: 암 측구 상폭 0.69→0.9). - let applied = JSON.stringify(defaults); + let applied = baseline(); const pending = document.createElement("p"); pending.className = "b06-std__pending"; pending.textContent = @@ -320,7 +341,8 @@ export function createStandardPanel( }; const persist = (): void => { - writeSession(projectId, state); + if (JSON.stringify(state) === baseline()) clearStandardCrossSession(projectId); + else writeSession(projectId, state); showPending(); }; @@ -416,8 +438,7 @@ export function createStandardPanel( body.replaceChildren(buildDetails()); }; // 열기 · 복원 · 불러오기 = 그룹 값 그대로(서버가 그 값으로 셈) — 공통 칸은 고칠 때만 펼침(364-6). - persist(); - if (!hadSession) seedPageHistory(stateKey("std-cross", projectId)); // 비었던 칸 채움 = 씨앗(364-1) + // 열 때는 세션에 안 씀 — 고친 값이 없으면 칸 없음(272). renderBody(); /** 소스 표준값을 현재 상태에 전부 덮어쓴다(사용자가 "적용"을 눌렀을 때만 호출). */ diff --git a/docs/wiki/concepts/design_page/operations_b06.md b/docs/wiki/concepts/design_page/operations_b06.md index 327ccb5bc..f617bd959 100644 --- a/docs/wiki/concepts/design_page/operations_b06.md +++ b/docs/wiki/concepts/design_page/operations_b06.md @@ -16,14 +16,14 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 조작 | 처음 놓이는 곳 | 브라우저 즉시 셈(모듈) | 서버 뒤 셈(API) | [저장]·[확정] 때 | 사본 | 위치(파일:줄) | |---|---|---|---|---|---|---| -| 카드 단추(지반 · 단면 · 측구 · 포장 · 2단) | 세션 `crossdesign` 만(메모리 상세 설계 안 덮음) | 횡단 다시 셈 · 카드 = 다시 셈 알림(`onCrossDesignsUpdated`)이 그림 | — | 횡단 패치 · 파이썬 설계선 다시 | 5 | `B06_Section_UI_Cross_Design.ts:278-289` · `B06_Section_UI_Page_Design_Sync.ts:59-104` · `B06_Section_Cross_Design_Session.ts:44-52` | +| 카드 단추(지반 · 단면 · 측구 · 포장 · 2단) | 세션 `crossdesign` 만 · 바꾼 칸만(앞서 바꾼 칸에 얹음 · 안 바꾼 지반 · 단면을 안 박음) · 메모리 상세 설계 안 덮음 | 횡단 다시 셈 · 카드 = 다시 셈 알림(`onCrossDesignsUpdated`)이 그림 | — | 횡단 패치 · 파이썬 설계선 다시 | 5 | `B06_Section_UI_Cross_Design.ts:278-289` · `B06_Section_UI_Page_Design_Sync.ts:59-104` · `B06_Section_Cross_Design_Session.ts:44-52` | | 설계선 쉬프트(노견 확장) | 세션 `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` | | 조정창(흙막이 · 집수정 · 유입 · 경간 · 세월교 · 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` | +| 표준횡단 · [전체 반영] | 세션 `std-cross` — 저장분(없으면 config)과 다를 때만 · 열기만 = 칸 없음 · 계산 = 세션 → 저장분 → config([폐기]에도 안 지움) | 전 측점 다시 셈(지문에 표준 포함) | — | `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/rules.md b/docs/wiki/concepts/design_page/rules.md index 271b5546c..0262b9440 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -33,6 +33,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | B05 · B06 캐시 = 한 벌 · [저장] 한 번에 함께 나감 | `design_shared.ts:395-448` · `design_save.ts:237` | — | | 관 · 구조물 주인 = 가게 칸 하나 — 화면 사본은 가게 알림으로 맞춤(제 쓰기는 같은 값 · 남 고침은 다시 실음) | `design_view_profile.ts` 가게 구독 · `design_view_cross.ts` onShop · `B06_Section_UI_Page_Structures_Panel.ts` seen | `test_472_5_shop_owner` | | 정본 자리 = 값을 낳은 주인 — 노선 시설 = `pipe_points.json` · `structures.json` · 횡단 값 = 횡단 패치 | `B05_Profile_Api_Pipes_Draft.ts:30-37` · `B05_Profile_Structures_Router.py:386-405` · `B06_Section_Section_Store.ts:449` | — | +| 열기만 = 초안 칸 없음 — 값이 저장분과 같으면 칸 없음(표준 횡단 = 결과 칸 `section-context` 에서 읽음) · 카드 고름 = 바꾼 칸만 · 소단 사본 = 떠남 확인 밖 | `B06_Section_UI_Standard_Panel.ts` effectiveStandardCross · persist · `B06_Section_UI_Cross_Design.ts` emit · `B06_Section_UI_Page_Design_Sync.ts` handleDesignChange · `design_leave.ts` NOT_EDITS | `test_472_open_no_draft` | | 관 자리 읽기 한 벌 = 지금 노선에 얹은 관 지점(측점 계획 · 관 지점 GET · 서버 상세 형상 얹기 · 카드 이름 · 세월교 노면 하강 · 물넘이포장 구간 · B07 관 글 · 화면) — 지문 어긋난 옛 저장분도 같은 자리 · 관 파일 누가거리를 그대로 읽지 않음 | `B05_Profile_Engine_Sections.py:249` route_pipe_points | `test_472_d_pipe_route_attach` · `test_472_d_route_pipe_readers` | ## 바꿀 때 할 일 diff --git a/docs/wiki/concepts/design_page/session_keys.md b/docs/wiki/concepts/design_page/session_keys.md index 8bc17d3a2..f014b2ba9 100644 --- a/docs/wiki/concepts/design_page/session_keys.md +++ b/docs/wiki/concepts/design_page/session_keys.md @@ -22,12 +22,12 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "A00_Common | `pipes` · `structures` · `plus-stations` | `:136` · `:127` · `:129` | 시설 줄 · 배수판 · B06 패널 · 추가 측점 창 | pipes · structures · plusStations | | `culvertopt` · `kindreset` | `:154` · `:152` | 관 조정창 옵션 예약 · 종류 바꾼 관 | culvertOptions · cross(structure_reset) | | `uphill` | `:131` | 상단측 | uphill(노선 바뀌면 cross 뒤) | -| `crossdesign` · `shoulderext` · `bermdepth` | `:196` · `:198` · `:200` | 카드 단추 · 설계선 쉬프트 · 소단 깊이 | cross | +| `crossdesign` · `shoulderext` · `bermdepth` | `:196` · `:198` · `:200` | 카드 단추(바꾼 칸만 · 앞서 바꾼 칸에 얹음) · 설계선 쉬프트 · 소단 깊이 | cross | | `rockb` · `cutslope` · `crossw` | `:158` · `:162` · `:184` | 암선 · 암 경사 · 측점 반폭 | cross | | `revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust` | `:185-192` | 조정창 칸(값 주인 · 창구 = b_page_state 열쇠 `B06_Section_Session_Cells` · 메모리 사본 없음 · 빈 칸 = 칸 지움) | cross | | `structure-notes` · `structure-note-cads` · `detail-table` | `:121` · `:123` · `:125` | [상세] 모달 | cross | -| `std-cross` | `:175` | 표준횡단 패널([폐기] · [저장]에도 안 지움 · [초기화]만) | cross(`standard_cross_section`) | -| `berm` | `:166` | 시설 줄 `syncBerm` 만(구조물 파생) | 안 감 — 서버가 `structures.json` 에서 | +| `std-cross` | `:175` | 표준횡단 패널 — 저장분(`section-context` 의 `stored_standard_cross_section`) · 없으면 config 와 다를 때만 · 같으면 칸 없음([폐기] · [저장]에도 안 지움 · [초기화]만) | cross(`standard_cross_section`) | +| `berm` | `:166` | 시설 줄 `syncBerm` 만(구조물 파생 · 열 때 씀 · 떠남 확인 밖) | 안 감 — 서버가 `structures.json` 에서 | | `cross-display` | `:177` | 전역 표시 반폭 | 안 감 | | `selection` · `structure-pick` · `pipe-ids` · `pipe-option-queue` · `structure-type-cache` | `:148` · `:138` · `:144` · `:146` · `:150` | 고름 · 관 id 장부 · 옵션 넘김 · 종류 캐시 | 안 감 · `structure-type-cache` 는 저장 뒤 비움 | diff --git a/resources/tester/helper_472_open_no_draft.mjs b/resources/tester/helper_472_open_no_draft.mjs new file mode 100644 index 000000000..eb0aafa38 --- /dev/null +++ b/resources/tester/helper_472_open_no_draft.mjs @@ -0,0 +1,195 @@ +/* 472 덧(요구 272 C · D) 헬퍼 — 진짜 모듈(helper_472_open_no_draft_entry.ts)로 + * ① 표준 횡단 패널 · 설정값 받기 = 세션 초안 칸 안 씀(같은 값이면 칸 없음) · 계산은 저장분 + * ② 카드 단추 = 바꾼 칸만 보냄 · 세션 고름은 앞서 바꾼 칸에 얹음 + * node helper_472_open_no_draft.mjs <묶음.mjs> <표준 횡단 고정 자료.json> + * 짝 = `test_472_open_no_draft.py` · 표준출력 JSON 한 벌. + */ +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const store = () => { + 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(), + }; +}; +/** 작은 가짜 요소 — 자식 · 듣는 함수 · 글만 들고 나머지는 빈 함수. */ +function element(tag) { + const base = { + tagName: String(tag).toUpperCase(), + children: [], + listeners: {}, + attrs: {}, + style: {}, + dataset: {}, + textContent: "", + classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, + append(...kids) { + base.children.push(...kids); + }, + prepend(...kids) { + base.children.unshift(...kids); + }, + insertBefore(kid) { + base.children.push(kid); + }, + replaceChildren(...kids) { + base.children = kids; + }, + addEventListener(type, fn) { + (base.listeners[type] ??= []).push(fn); + }, + setAttribute(k, v) { + base.attrs[k] = String(v); + }, + getAttribute: (k) => base.attrs[k] ?? null, + querySelector: () => null, + querySelectorAll: () => [], + closest: () => null, + clientWidth: 0, + offsetWidth: 0, + scrollWidth: 0, + }; + return new Proxy(base, { + get: (t, k) => (k in t ? t[k] : typeof k === "string" ? () => undefined : undefined), + }); +} +const noop = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : noop), + apply: () => noop, + construct: () => noop, +}); +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +globalThis.document = new Proxy( + { createElement: element, createElementNS: (_ns, tag) => element(tag) }, + { get: (t, k) => (k in t ? t[k] : noop) }, +); +globalThis.ResizeObserver = class { + observe() {} + disconnect() {} +}; +globalThis.getComputedStyle = () => ({ gap: "0" }); +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); +globalThis.addEventListener = () => undefined; +globalThis.removeEventListener = () => undefined; +globalThis.fetch = async () => new Response("{}", { status: 404 }); + +const m = await import(pathToFileURL(process.argv[2]).href); +const draftKeys = () => + Object.keys( + Object.fromEntries([...Array(sessionStorage.length)].map((_, i) => [sessionStorage.key(i), 1])), + ).filter((k) => k.startsWith("aislo:draft:")); +const out = {}; + +// ① 표준 횡단 — config 와 저장분이 다른 프로젝트 +const config = JSON.parse(readFileSync(process.argv[3], "utf8")).standard_cross_section; +const stored = JSON.parse(JSON.stringify(config)); +stored.rock.ditch.top_width_m = 0.9; // 저장분 = 사용자가 고쳐 저장한 값(config 와 다름) +const context = { + project_id: "pS", + standard_cross_section: config, + stored_standard_cross_section: stored, +}; +m.writeState("section-context", context, "pS"); +await m.fetchSectionContext("pS"); // 캐시 길 — 예전엔 여기서 저장분을 초안 칸에 베낌 +out.ctxDrafts = draftKeys(); +const panel = m.createStandardPanel("pS", config, () => {}); +out.panelDrafts = draftKeys(); +out.panelRockDitch = panel.getValues().rock.ditch.top_width_m; +out.effectiveRockDitch = m.effectiveStandardCross("pS").rock.ditch.top_width_m; +panel.applyStored(stored); +out.afterStored = draftKeys(); +// 옛 판이 남긴 같은 값 칸 — 열면 걷음 +m.writeState("std-cross", stored, "pS2"); +m.writeState("section-context", { ...context, project_id: "pS2" }, "pS2"); +m.createStandardPanel("pS2", config, () => {}); +out.oldSameCell = m.readState("std-cross", "pS2"); +// 저장분 없는 프로젝트 — config 그대로 = 칸 없음 · 계산은 config +m.writeState("section-context", { project_id: "pC", standard_cross_section: config }, "pC"); +m.createStandardPanel("pC", config, () => {}); +out.configDrafts = draftKeys(); +out.configRockDitch = m.effectiveStandardCross("pC").rock.ditch.top_width_m; +// 사용자가 고친 값은 칸에 남음 +const edited = JSON.parse(JSON.stringify(stored)); +edited.soil.road_width_m = 3.5; +m.writeState("std-cross", edited, "pE"); +m.writeState("section-context", { ...context, project_id: "pE" }, "pE"); +m.createStandardPanel("pE", config, () => {}); +out.editedRoadWidth = m.readState("std-cross", "pE")?.soil.road_width_m ?? null; + +// ② 카드 단추 — 리핑암 · 좌절토 · 측구 자동 판정 +sessionStorage.clear(); +const section = { + station_id: "No.1", + chainage_m: 20, + uphill_side: "left", + design: { + ground_type: "ripping_rock", + section_mode: "left_cut", + ditch_side: null, + ditch_type: "standard", + paved: false, + two_stage_slope: false, + ditch_choice: null, + ditch_enabled: true, + }, + samples: [], +}; +const sent = []; +const { bar, groundSegment } = m.buildDesignControls(section, (_c, change) => sent.push(change)); +const buttons = []; +const walk = (node) => { + if (!node || typeof node !== "object") return; + if (node.tagName === "BUTTON") buttons.push(node); + (node.children ?? []).forEach(walk); +}; +walk(groundSegment); +walk(bar); +const press = (pick) => buttons.find(pick)?.listeners.click?.forEach((fn) => fn({})); +out.soilPressed = buttons[0]?.getAttribute("aria-pressed"); +press( + (b) => b.textContent === "측구" || b.textContent === "측구 없음" || /측구/.test(b.textContent), +); +out.ditchChange = sent.at(-1) ?? null; +// 새 카드 · 토사 단추 — 지반만(측구형식은 이미 일반이라 안 실림) +buttons.length = 0; +const again = m.buildDesignControls(section, (_c, change) => sent.push(change)); +walk(again.groundSegment); +press(() => true); +out.soilChange = sent.at(-1) ?? null; + +// 세션 고름 — 앞서 바꾼 칸에 얹음(지반은 안 박음) +const detail = { cross_sections: [section] }; +const sync = m.createDesignSync({ + projectId: "pD", + routeId: () => 7, + detail: () => detail, + view: () => noop, +}); +await sync.handleDesignChange(20, { ditch_choice: false }).catch(() => undefined); +await sync.handleDesignChange(20, { two_stage_slope: true }).catch(() => undefined); +out.choice = Object.fromEntries(m.crossDesignChoices("pD", 7)); +await sync.handleDesignChange(20, {}).catch(() => undefined); +out.choiceAfterEmpty = Object.fromEntries(m.crossDesignChoices("pD", 7)); +// 소단 사본(시설 줄이 열 때 구조물에서 씀) — 떠남 확인에 안 잡힘 · 진짜 고침은 잡힘 +sessionStorage.clear(); +m.writeState( + "berm", + [{ start_m: 0, end_m: 40, width_m: 1, interval_m: 5, slope_deg: 45, depth_m: 1 }], + "pB", + 7, +); +out.dirtyBermOnly = m.designDirty("pB"); +m.writeCrossDesignChoice("pB", 7, 20, { ditch_choice: false }); +out.dirtyWithEdit = m.designDirty("pB"); +process.stdout.write(JSON.stringify(out)); +process.exit(0); diff --git a/resources/tester/helper_472_open_no_draft_entry.ts b/resources/tester/helper_472_open_no_draft_entry.ts new file mode 100644 index 000000000..36ad3d813 --- /dev/null +++ b/resources/tester/helper_472_open_no_draft_entry.ts @@ -0,0 +1,15 @@ +/* 472 덧(요구 272 C · D) 시험 묶음 진입점 — 열기만 = 초안 칸 없음 · 카드 고름 = 바꾼 칸만(vite ssr). */ +export { + createStandardPanel, + effectiveStandardCross, + rememberStandardDefaults, +} from "../../B06_Section/B06_Section_UI_Standard_Panel"; +export { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Context"; +export { buildDesignControls } from "../../B06_Section/B06_Section_UI_Cross_Design"; +export { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync"; +export { + crossDesignChoices, + writeCrossDesignChoice, +} from "../../B06_Section/B06_Section_Cross_Design_Session"; +export { readState, writeState } from "../../A00_Common/b_page_state"; +export { designDirty } from "../../A00_Common/design/design_leave"; diff --git a/resources/tester/test_364_6_std_panel_same_value.py b/resources/tester/test_364_6_std_panel_same_value.py index c69406c55..1a1bfbefa 100644 --- a/resources/tester/test_364_6_std_panel_same_value.py +++ b/resources/tester/test_364_6_std_panel_same_value.py @@ -81,7 +81,8 @@ def test_B06_칸_열어도_B05_값(case, out): @pytest.mark.parametrize("case", ["config", "stored"]) def test_칸_열기는_표준_횡단을_안_고침(case, out): - assert out[case]["session"] == out["standard"] + # 472 덧(요구 272 C) — 저장분 · config 와 같은 값이면 초안 칸 없음(옛 판은 같은 값을 칸에 씀) + assert out[case]["session"] is None def test_고치기_전_공통_칸_끝에_암_값(out): diff --git a/resources/tester/test_472_open_no_draft.py b/resources/tester/test_472_open_no_draft.py new file mode 100644 index 000000000..4b3b4b46a --- /dev/null +++ b/resources/tester/test_472_open_no_draft.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +"""472 덧(요구 272 C · D) — B06 를 열기만 해도 초안이 생기던 흠 · +카드 고름이 안 바꾼 지반을 박던 흠. + +까닭 + C. 표준 횡단 패널이 열 때 세션 `std-cross` 에 config 기본값을, 설정값 받기(`seedStandardCross`)가 + 저장분을 베껴 씀 → 조작 없이 초안 칸이 섬. 규칙 = 값이 저장분과 같으면 칸 없음 · 계산은 저장분. + D. 카드 단추 하나를 눌러도 지반 · 단면 유형 · 측구형식 · 포장 등 일곱 칸을 통째로 세션에 실음 → + [측구] 만 눌렀는데 ripping_rock 이 사용자 선택으로 박힘. 규칙 = 바꾼 칸만. + 덧. 소단 사본(`berm` · 시설 줄이 열 때 구조물에서 씀)이 떠남 확인 고침으로 잡힘 → + 소단 있는 프로젝트는 열기만 해도 물음. + +Node 헬퍼 `helper_472_open_no_draft.mjs` 가 vite ssr 로 묶은 진짜 모듈 +(`helper_472_open_no_draft_entry.ts`)을 부름. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +FIXTURE = HERE / "fixtures" / "b06_364_6_rock_route_304.json" + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b472_open") + build = subprocess.run( + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_472_open_no_draft_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_472_open_no_draft_entry.js", bundle) + proc = subprocess.run( + ["node", str(HERE / "helper_472_open_no_draft.mjs"), str(bundle), str(FIXTURE)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("step", ["ctxDrafts", "panelDrafts", "afterStored", "configDrafts"]) +def test_열기만_하면_초안_칸_0(step, out): + assert out[step] == [] + + +def test_계산은_저장분(out): + assert out["panelRockDitch"] == 0.9 + assert out["effectiveRockDitch"] == 0.9 + + +def test_저장분_없으면_config(out): + assert out["configRockDitch"] == 0.69 + + +def test_옛_판이_남긴_같은_값_칸은_걷음(out): + assert out["oldSameCell"] is None + + +def test_고친_값은_칸에_남음(out): + assert out["editedRoadWidth"] == 3.5 + + +def test_카드_지반_글_상태는_상세_ground_type(out): + # 상세 = ripping_rock → 「토사」 단추 꺼짐(단추 이름은 늘 「토사」 · 켜짐/꺼짐이 값) + assert out["soilPressed"] == "false" + + +def test_측구_단추는_측구_칸만(out): + assert out["ditchChange"] == {"ditch_choice": False} + + +def test_토사_단추는_지반_칸만(out): + assert out["soilChange"] == {"ground_type": "soil"} + + +def test_세션_고름은_바꾼_칸끼리_얹음(out): + assert out["choice"] == {"20": {"ditch_choice": False, "two_stage_slope": True}} + assert out["choiceAfterEmpty"] == out["choice"] + + +def test_소단_사본은_떠남_확인에_안_잡힘(out): + assert out["dirtyBermOnly"] is False + assert out["dirtyWithEdit"] is True diff --git a/resources/tester/test_b06_stored_standard_reaches_browser.py b/resources/tester/test_b06_stored_standard_reaches_browser.py index 3fa7c5ec8..de129de98 100644 --- a/resources/tester/test_b06_stored_standard_reaches_browser.py +++ b/resources/tester/test_b06_stored_standard_reaches_browser.py @@ -7,7 +7,8 @@ config 기본값은 **3%** · **0.69m**. 표준단면은 모든 측점의 횡단 면적·유토곡선·수량까지 그대로 흐른다. 고침은 두 줄이다 — `sections/context` 가 저장분을 **한 칸 더** 실어 보내고(기존 기본값 칸은 -그대로), 브라우저가 **세션이 비었을 때만** 그 값으로 세운다. 이 시험은 그 두 성질을 지킨다. +그대로), 브라우저가 **세션이 비었을 때만** 그 값으로 셈(초안 칸에 베끼지 않음 · 472 덧). +이 시험은 그 두 성질을 지킨다. """ import re @@ -53,12 +54,18 @@ def test_라우터가_저장분을_실어_보낸다(): def test_브라우저는_세션이_빈_경우에만_저장분으로_선다(): - """사용자가 그 탭에서 고친 값이 있으면 건드리면 안 된다 — 초안이 언제나 우선.""" + """사용자가 그 탭에서 고친 값이 있으면 그 값 — 없으면 저장분(472 덧 · 요구 272 C). + + 옛 판은 세션이 비면 저장분을 초안 칸(`std-cross`)에 베꼈음 → 열기만 해도 초안이 섬. + 지금은 설정값 칸(`section-context`)에서 바로 읽음 — 세션 고침 → 저장분 → config 차례. + """ source = _FETCH.read_text(encoding="utf-8") - assert "function seedStandardCross(" in source, "세우는 자리가 없음" - guard = re.search( - r"if \(stored && readState\(\"std-cross\", projectId\) === null\)", source + assert '"std-cross"' not in source, "설정값 받기가 초안 칸에 씀" + panel = (_FETCH.parent / "B06_Section_UI_Standard_Panel.ts").read_text(encoding="utf-8") + assert "stored_standard_cross_section" in panel, "저장분을 안 읽음" + order = re.search( + r"readStandardCrossSession\(projectId\) \?\?\s*readStoredStandard\(projectId\) \?\?\s*" + r"readStandardDefaults\(projectId\)", + panel, ) - assert guard, "세션이 있어도 덮어쓰는 모양임" - # 두 갈래(캐시·새 조회) 모두 지나야 새 탭에서도 선다. - assert source.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움" + assert order, "세션 → 저장분 → config 차례가 아님" diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index e2026d432..ca94329e9 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -219,8 +219,13 @@ def test_저장된_표준단면이_브라우저까지_간다(): fetch = (PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Context.ts").read_text( encoding="utf-8" ) - assert "function seedStandardCross(" in fetch - assert fetch.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움" + # 472 덧(요구 272 C) — 초안 칸에 베끼던 seedStandardCross 대신 + # 계산이 설정값 칸 저장분을 바로 읽음 + assert '"std-cross"' not in fetch, "설정값 받기가 초안 칸에 씀" + panel = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Standard_Panel.ts").read_text( + encoding="utf-8" + ) + assert "readStoredStandard(projectId) ??" in panel, "계산이 저장분을 안 봄" # ── ⑨ 다단 기슭막이가 물량에 안 닿던 것 (`e9ad8cdf`) ──────────────────────────