From 9feba3de1019325f19356c936f2ca6805da178c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 12:22:53 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(B05=C2=B7B06=20=ED=95=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80):=20=EC=A0=80=EC=9E=A5=20=EB=81=9D=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=ED=95=9C=20=EB=B2=88=20=EB=B9=84=EC=9B=80?= =?UTF-8?q?=20=C2=B7=20=EC=A2=85=EB=8B=A8=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EA=B4=80=20=EA=B8=B8=EC=9D=B4=20=3D=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EC=BA=90=EC=8B=9C=20=C2=B7=20=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=20=ED=9A=A1=EB=8B=A8=20=ED=8C=90=20=3D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=85=B8=EC=84=A0=20=C2=B7=20=EC=95=94?= =?UTF-8?q?=EC=84=A0=20=EA=B8=B0=EB=B3=B8=20config=20(416-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M1: [저장] 끝 한 벌이 상세 캐시를 한 번 비움 · 종단 보기 done 이 새 상세 다시 읽어 종단 · 평면 판 그림 (보기마다 비우던 것 지움) - M5: 관 길이 출처 = 페이지가 거는 공용 캐시(페이지 노선) 한 벌 · 보기마다 걸던 것 지움 - L2: peekSectionDetail(projectId, routeId) = 그 노선 상세 · 가장 큰 노선 번호 고르기 지움 - 암선 기본 = 종단 보기가 기억한 context(config) 값 - 시험 test_416_page_cache.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_page.ts | 7 + A00_Common/design/design_shared.ts | 4 + A00_Common/design/design_view_cross.ts | 3 - A00_Common/design/design_view_profile.ts | 4 +- B05_Profile/B05_Profile_UI_Page_Actions.ts | 7 +- .../B05_Profile_UI_RouteEdit_Cross_Card.ts | 24 ++- B06_Section/B06_Section_Section_Store.ts | 15 +- B06_Section/B06_Section_UI_Page_Persist.ts | 4 +- .../tester/helper_387_8_folded_profile.cjs | 2 +- resources/tester/helper_416_page_cache.cjs | 184 ++++++++++++++++++ resources/tester/test_416_page_cache.py | 86 ++++++++ 11 files changed, 311 insertions(+), 29 deletions(-) create mode 100644 resources/tester/helper_416_page_cache.cjs create mode 100644 resources/tester/test_416_page_cache.py diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index e3bb1ac5c..a99cb8563 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -35,6 +35,9 @@ import { pinDraftProject } from "./design_store"; import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center"; import { reconcilePagePipeline } from "./design_pipeline_page"; import { forgetPageData, onPageBusy } from "./design_page_data"; +import { pageRouteId } from "./design_page_route"; +import { setPipeSectionsSource } from "./design_structure_pipe_calc"; +import { peekSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import "./design_page.css"; export type { DesignPageShared } from "./design_shared"; @@ -113,6 +116,10 @@ export function renderDesignPage(root: HTMLElement): MountedPage { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로 usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id) + // 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) + setPipeSectionsSource( + () => peekSectionDetail(projectId, pageRouteId(projectId))?.cross_sections ?? null, + ); // 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다. const left = document.createElement("div"); diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 6e387f993..a04f8f8b0 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -39,6 +39,7 @@ import { } from "../../B06_Section/B06_Section_Structure_Layouts"; import { holdSectionDetail, + invalidateSectionDetail, restoreSectionDetail, swapSectionStations, } from "../../B06_Section/B06_Section_Section_Store"; @@ -382,6 +383,9 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]); return; } + // 서버가 정본(계획선 · 횡단 · 면적)을 다시 썼다 — 공유 캐시를 한 번 비우고 두 보기가 새 상세 한 객체를 + // 받음(416 · 보기마다 비우면 앞 보기가 받은 새 상세를 뒤 보기가 또 버림) + invalidateSectionDetail(shared.projectId); await profileSave?.done?.(false); await crossSave?.done?.(false); showToast(L("B06_Profile_Save_Success"), "success"); diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 8cf1ac684..2d383e2b5 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -79,7 +79,6 @@ import { setPipeExtraCountSource, setPipeInletSource, setPipeRevetChoiceBridge, - setPipeSectionsSource, } from "./design_structure_pipe_calc"; import { pipeRevetChoiceBridge } from "../../B06_Section/B06_Section_UI_Page_Link_Session"; import { @@ -103,8 +102,6 @@ export async function mountCrossView( const projectId = shared.projectId || null; let currentRouteId: number | null = null; let sectionDetail: SectionDetailResponse | null = null; - // 배관 명세 관 길이(자동) = 이 횡단 자료의 기하(PLAN 52-1) - setPipeSectionsSource(() => sectionDetail?.cross_sections ?? null); let stationInterval: number | undefined; // 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다. diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 9799e068c..902e38bb1 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -59,7 +59,7 @@ import { type PageActionContext, } from "../../B05_Profile/B05_Profile_UI_Page_Actions"; import { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; -import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc"; +import { setPipeCrossOpener } from "./design_structure_pipe_calc"; import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types"; import "../../B05_Profile/B05_Profile_UI_Style.css"; // 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). @@ -245,8 +245,6 @@ export async function mountProfileView( routeReady: false, restoring: true, }; - // 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06) - setPipeSectionsSource(() => state.detail?.cross_sections ?? null); // 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27) setPipeCrossOpener(() => shared.ensureView("cross")); // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 383de287c..f9f79c93d 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -9,7 +9,6 @@ import { confirmRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; -import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store"; import { pageRouteId } from "../A00_Common/design/design_page_route"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; @@ -74,10 +73,12 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave { }, }, // 횡단 본문은 늘 횡단 보기 몫 — [저장]이 숨긴 채라도 세움(386-6 · 옛 캐시 패치 길 지움) + // 공유 캐시는 한 벌(`design_shared`)이 저장 끝에 한 번 비움 — 여기서는 새 자료를 다시 읽어 종단 · + // 평면 오른쪽 판을 그림(416 · 노선 안 바뀐 [저장]도 · B06 다녀오지 않아도 면적 = B06) done: async () => { - // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 횡단 보기가 옛 계획선을 못 보게 한다. - invalidateSectionDetail(projectId); ctx.renderLatest(await ctx.loadLatest(true)); + const routeId = pageRouteId(projectId); + if (routeId !== null) await ctx.restoreSections(routeId); }, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts index 7bb1d1e87..f68879dce 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts @@ -32,6 +32,7 @@ import type { FordControl } from "../B06_Section/B06_Section_UI_Cross_Ford_Panel import { createCrossSectionCard } from "../B06_Section/B06_Section_UI_Cross_View"; import type { RockBoundaryControl } from "../B06_Section/B06_Section_UI_Cross_Design"; import { designElevationAt } from "../B06_Section/B06_Section_UI_Section_Common"; +import { readRockBoundaryDefault } from "../B06_Section/B06_Section_UI_Standard_Panel"; /** 판 하나가 그릴 측점 — 그때 사본(이전 판으로 내려도 그대로). */ export interface RouteCrossSource { @@ -40,18 +41,24 @@ export interface RouteCrossSource { link: CulvertLink | undefined; /** 보기 반폭(m) — B06 입력칸과 같은 값 · 그릴 때마다 읽음(B06 에서 바꾸면 다음 그림부터). */ halfWidthM: () => number; + /** 암 경계 기본 오프셋(m · config). */ + rockDefaultM: number; } const none = (): void => undefined; -/** 암 경계선 = 정본 오프셋(없으면 기본 −0.5 · 0 을 넘지 않음 — B06 저장소 규칙) · 조정 없음. */ -const rockBoundary: RockBoundaryControl = { +/** 암 경계선 = 정본 오프셋(없으면 config 기본 · 0 을 넘지 않음 — B06 저장소 규칙) · 조정 없음. */ +const rockBoundary = (defaultOffsetM: number): RockBoundaryControl => ({ stepM: 0.1, - defaultOffsetM: -0.5, - offsetFor: (section) => Math.min(section.design?.rock_boundary_offset_m ?? -0.5, 0), + defaultOffsetM, + offsetFor: (section) => Math.min(section.design?.rock_boundary_offset_m ?? defaultOffsetM, 0), adjust: none, reset: none, -}; +}); + +/** config 암 경계 기본(`STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M` · 종단 보기가 context 로 기억 · 416) — + * context 전이면 서버 Schema 기본과 같은 값. */ +const CONTEXT_PENDING_ROCK_M = -0.5; /** 그 측점 단면 사본 — 상세가 없거나(초안 중 새 상세 전) 측점이 없으면 null. */ export function routeCrossSource( @@ -60,8 +67,10 @@ export function routeCrossSource( chainageM: number, pane: string, ): RouteCrossSource | null { + const routeId = pageRouteId(projectId); const detail = - pipelineDetail(projectId) ?? (pipe?.input("route") ? null : peekSectionDetail(projectId)); + pipelineDetail(projectId) ?? + (pipe?.input("route") ? null : peekSectionDetail(projectId, routeId)); const sections = detail?.cross_sections ?? []; const found = nearestWithin(sections, chainageM, (entry) => entry.chainage_m); if (!detail || !found) return null; @@ -78,6 +87,7 @@ export function routeCrossSource( ? undefined : culvertLinkFor(found, sections, (at) => designElevationAt(profiles, at) ?? null), halfWidthM: () => displayHalfWidth(projectId, pageRouteId(projectId), sections), + rockDefaultM: readRockBoundaryDefault(projectId) ?? CONTEXT_PENDING_ROCK_M, }; } @@ -122,7 +132,7 @@ export function routeCrossCard( plotHeightPx, source.designZ, undefined, - rockBoundary, + rockBoundary(source.rockDefaultM), null, undefined, undefined, diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index a78f05d03..381c4ec34 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -302,15 +302,12 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo } } -/** 받아 둔 상세 중 노선 번호가 가장 큰 것(최신) — 노선 번호를 모르는 쪽(B05 평면 횡단 판 · 393-2). */ -export function peekSectionDetail(projectId: string): SectionDetailResponse | null { - const prefix = `${projectId}:`; - let best: { routeId: number; detail: SectionDetailResponse } | null = null; - for (const [key, detail] of cache) { - const routeId = Number(key.slice(prefix.length)); - if (key.startsWith(prefix) && (!best || routeId > best.routeId)) best = { routeId, detail }; - } - return best?.detail ?? null; +/** 받아 둔 그 노선 상세(받지 않고 · 없으면 null) — 노선 = 페이지 노선 id(B05 평면 횡단 판 · 관 길이 · 416). */ +export function peekSectionDetail( + projectId: string, + routeId: number | null, +): SectionDetailResponse | null { + return routeId === null ? null : (cache.get(keyOf(projectId, routeId)) ?? null); } /** diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index fc804489e..0a02dbbc3 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 { invalidateSectionDetail } from "./B06_Section_Section_Store"; 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"; @@ -520,8 +519,7 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave { // 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다 // (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음). // 저장 도중 바뀜 신호는 안 그렸음 — 비운 캐시로 서버 새 상세를 받아 한 번 다시(323-9) - const routeId = ctx.routeId(); - if (ctx.projectId && routeId !== null) invalidateSectionDetail(ctx.projectId, routeId); + // 캐시 비우기 = 한 벌(`design_shared`)이 저장 끝에 한 번(416 · 두 보기가 같은 새 객체를 받게) await ctx.redraw(); }, }; diff --git a/resources/tester/helper_387_8_folded_profile.cjs b/resources/tester/helper_387_8_folded_profile.cjs index cd0e0dc96..36e8f3242 100644 --- a/resources/tester/helper_387_8_folded_profile.cjs +++ b/resources/tester/helper_387_8_folded_profile.cjs @@ -128,7 +128,7 @@ function loadTs(full) { const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); const store = loadTs(B06("B06_Section_Section_Store")); const { writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); -const storeDetail = () => store.peekSectionDetail(P); +const storeDetail = () => store.peekSectionDetail(P, ROUTE); /** 저장 정본 — 격자 0 · 20 · 40 · 60(지금 계획선으로 셈한 설계) · 계획선 평평 100. */ const canon = () => ({ diff --git a/resources/tester/helper_416_page_cache.cjs b/resources/tester/helper_416_page_cache.cjs new file mode 100644 index 000000000..3f5d5f893 --- /dev/null +++ b/resources/tester/helper_416_page_cache.cjs @@ -0,0 +1,184 @@ +/* 416 헬퍼 — 한 페이지 끊김 ② : 진짜 상세 가게(`B06_Section_Section_Store`) · 진짜 평면 횡단 판 자료 + * (`B05_Profile_UI_RouteEdit_Cross_Card.routeCrossSource`) · 진짜 관 길이(`design_structure_pipe_calc`) · + * 진짜 페이지 노선 칸. 서버 · 그림 · 묶음은 가짜. 짝 = `test_416_page_cache.py`. + * L2 — 판 = 페이지 노선 상세(더 큰 노선 번호 상세가 캐시에 있어도) + * M5 — 관 길이 = 공용 캐시(페이지 노선) · 저장 뒤 비우고 다시 받으면 새 값 + * M1 — 비운 뒤 두 보기가 함께 받으면 같은 새 객체 하나 + * 암선 기본 = config(기억해 둔 context 값) + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const P = "P416"; + +function memoryStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + key: (i) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() }; + +const anyModule = (extra = {}) => + new Proxy(extra, { get: (t, key) => (key in t ? t[key] : () => undefined) }); + +/** 가짜 서버 — 노선마다 관 길이 하나(40 m 측점 관) · 받을 때마다 새 객체 · 받은 수. */ +const server = { pipeLength: { 3: 8, 9: 30 }, fetches: 0 }; +const detailOf = (routeId) => ({ + longitudinal: { stations: [], design_profiles: [] }, + cross_sections: [0, 20, 40].map((chainage_m) => ({ + station_id: `r${routeId}-${chainage_m}`, + chainage_m, + samples: [], + culvert: chainage_m === 40 ? { chainage_m: 40 } : undefined, + design: { pipe_length_m: chainage_m === 40 ? server.pipeLength[routeId] : undefined }, + })), +}); +let rockDefault = null; // 종단 보기가 기억한 context 값 +let draftRoute = null; // 묶음 노선 초안 + +const STUBS = { + "design_pipeline_page.ts": { + reconcilePagePipeline: async () => 0, + pipelineOwnsCross: () => false, + pagePipeline: () => null, + pipelineCross: () => undefined, + pipelineDetail: () => null, + }, + "design_pipeline_cross.ts": { forceCross: () => undefined }, + "B06_Section_Section_Drafts.ts": { + forgetDraftStations: () => undefined, + withDraftShapes: async () => undefined, + withDraftStations: async () => undefined, + }, + "B05_Profile_Api_PlusStations.ts": { + loadSavedPlusStations: async () => undefined, + plusStationsOf: () => [], + }, + "B05_Profile_Api_Pipes_Draft.ts": { readPendingPipes: () => null }, + "design_page_data.ts": { pagePipes: async () => ({ pipe_points: [] }) }, + "B06_Section_Api_Fetch.ts": anyModule({ + fetchSectionDetail: async (_projectId, routeId) => { + server.fetches += 1; + return detailOf(routeId); + }, + }), + "B06_Section_Api_Culvert_Options.ts": anyModule({ STRUCTURE_ADJUST_KEYS: [] }), + "B06_Section_Swap_Bus.ts": anyModule(), + "B06_Section_Station_Keys.ts": anyModule({ KEYED_STATES: [] }), + "B06_Section_UI_Page_Persist.ts": anyModule(), + "B06_Section_UI_Standard_Panel.ts": anyModule({ + readRockBoundaryDefault: () => rockDefault, + }), + "B06_Section_Cross_Design_Session.ts": anyModule(), + "common_util_cross_design.ts": anyModule(), + "B06_Section_Structure_Layouts.ts": anyModule({ computeStoredLayouts: () => null }), + "B06_Section_UI_Cross_Culvert_Wire.ts": anyModule(), + "B06_Section_UI_Cross_Ford_Geom.ts": anyModule(), + "B06_Section_UI_Cross_Box_Geom.ts": anyModule(), + "B06_Section_UI_Cross_View.ts": anyModule(), + "B06_Section_UI_Section_Common.ts": anyModule({ designElevationAt: () => undefined }), + "B06_Section_Display_Width.ts": anyModule({ displayHalfWidth: () => 10 }), + "B06_Section_UI_Style_Cross_Controls.css": {}, + "B06_Section_UI_Cross_Culvert_Basin.ts": anyModule(), + "B06_Section_UI_Cross_Culvert_Const.ts": anyModule(), + "B06_Section_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }), + "B05_Profile_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }), + "config_frontend.ts": anyModule({ PIPE_TYPES: [] }), +}; + +const cache = new Map(); +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; +function loadTs(full) { + const stub = STUBS[path.basename(full)]; + if (stub) return stub; + 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, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const near = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +const store = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts")); +const pageRoute = loadTs(path.join(ROOT, "A00_Common", "design", "design_page_route.ts")); +const pipeCalc = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe_calc.ts")); +const card = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Cross_Card.ts")); +const fakePipe = { input: (name) => (name === "route" ? draftRoute : null) }; + +async function run() { + const out = {}; + // 열 때 — 페이지 노선 3 · 캐시에 더 큰 노선 9 도 있음(다른 탭 · 옛 판) + pageRoute.setPageRouteId(P, 3); + await store.loadSectionDetail(P, 9); + const shown = await store.loadSectionDetail(P, 3); + const source = card.routeCrossSource(P, fakePipe, 40, "cur"); + out.l2 = { + station: source?.section.station_id ?? null, + noRoute: store.peekSectionDetail(P, null), + exact: store.peekSectionDetail(P, 3) === shown, + }; + draftRoute = { v: 1 }; + out.l2.draftNoSaved = card.routeCrossSource(P, fakePipe, 40, "cur"); // 노선 초안 중 = 저장 상세 안 씀 + draftRoute = null; + + // 암선 기본 = config(기억값) · 기억 전 = 서버 Schema 기본과 같은 값 + out.rock = { pending: source.rockDefaultM }; + rockDefault = -0.8; + out.rock.config = card.routeCrossSource(P, fakePipe, 40, "cur").rockDefaultM; + + // M5 — 관 길이 = 공용 캐시(페이지 노선) 한 벌(design_page 가 거는 그 함수) + pipeCalc.setPipeSectionsSource( + () => store.peekSectionDetail(P, pageRoute.pageRouteId(P))?.cross_sections ?? null, + ); + out.m5 = { before: pipeCalc.pipeLengthM(40) }; + // [저장] — 서버가 관 길이를 다시 셈(8 → 11) · 한 벌이 캐시를 한 번 비움 → 두 보기가 함께 다시 받음 + server.pipeLength[3] = 11; + store.invalidateSectionDetail(P); + out.m5.cleared = pipeCalc.pipeLengthM(40); + const fetchesBefore = server.fetches; + const [profileView, crossView] = await Promise.all([ + store.loadSectionDetail(P, 3), + store.loadSectionDetail(P, 3), + ]); + out.m1 = { + sameObject: profileView === crossView, + fetched: server.fetches - fetchesBefore, + fresh: profileView !== shown, + panelSeesFresh: card.routeCrossSource(P, fakePipe, 40, "cur")?.section.station_id ?? null, + }; + out.m5.after = pipeCalc.pipeLengthM(40); + return out; +} +run() + .then((out) => console.log(JSON.stringify(out))) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/resources/tester/test_416_page_cache.py b/resources/tester/test_416_page_cache.py new file mode 100644 index 000000000..86a602c3c --- /dev/null +++ b/resources/tester/test_416_page_cache.py @@ -0,0 +1,86 @@ +"""PLAN 416 sub5 — 한 페이지 끊김 ② : 저장 뒤 새 자료(M1) · 관 길이 출처(M5) · 오른쪽 판 노선 id(L2) · 암선 기본 config. + +짝: `helper_416_page_cache.cjs`(진짜 상세 가게 · 평면 횡단 판 자료 · 관 길이 · 페이지 노선 칸 · 서버 · 그림 가짜). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( # noqa: S603, S607 — 고정 실행 파일 + ["node", str(HERE / "helper_416_page_cache.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_L2_판은_페이지_노선_상세(r): + assert r["l2"]["station"] == "b05-cur-r3-40" # 캐시에 더 큰 노선 9 가 있어도 + assert r["l2"]["noRoute"] is None and r["l2"]["exact"] is True + assert r["l2"]["draftNoSaved"] is None # 노선 초안 중 = 저장 상세 안 씀 + + +def test_암선_기본은_config(r): + assert r["rock"] == {"pending": -0.5, "config": -0.8} + src = _read("B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts") + assert "readRockBoundaryDefault(projectId)" in src + assert "?? -0.5" not in src + + +def test_M5_관_길이는_공용_캐시(r): + assert r["m5"] == {"before": 8, "cleared": None, "after": 11} + page = _read("A00_Common/design/design_page.ts") + assert "peekSectionDetail(projectId, pageRouteId(projectId))?.cross_sections" in page + found = subprocess.run( # noqa: S603, S607 — 고정 실행 파일 + ["git", "grep", "-l", "setPipeSectionsSource(", "--", "*.ts"], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + ).stdout.split() + # 거는 곳 = 페이지 하나(보기마다 걸면 마지막 연 보기 사본) + assert sorted(found) == [ + "A00_Common/design/design_page.ts", + "A00_Common/design/design_structure_pipe_calc.ts", + ] + + +def test_M1_비운_뒤_두_보기는_새_객체_하나(r): + assert r["m1"] == { + "sameObject": True, + "fetched": 1, + "fresh": True, + "panelSeesFresh": "b05-cur-r3-40", + } + + +def test_M1_저장_끝_한_번_비우고_종단_다시_읽음(): + shared = _read("A00_Common/design/design_shared.ts") + done = shared[shared.index("done: async (isConfirm)") :] + assert done.index("invalidateSectionDetail(shared.projectId)") < done.index("profileSave?.done") + actions = _read("B05_Profile/B05_Profile_UI_Page_Actions.ts") + tail = actions[actions.index("done: async") :] + assert tail.index("ctx.loadLatest(true)") < tail.index("ctx.restoreSections(routeId)") + assert "invalidateSectionDetail" not in actions + persist = _read("B06_Section/B06_Section_UI_Page_Persist.ts") + assert ( + "invalidateSectionDetail" not in persist + ) # 보기마다 비우면 앞 보기가 받은 새 상세를 또 버림 From 67de77c48c9867d52982126de046955c8a18f14f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 12:32:41 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(B05=20=EB=85=B8=EC=84=A0=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=B0=BD):=20=EC=B8=A1=EC=A0=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=E2=86=92=20=EC=A2=85=EB=8B=A8=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=C2=B7=20[=EC=9E=AC=EA=B3=84=EC=82=B0]?= =?UTF-8?q?=20=EB=8A=98=20=EC=BC=9C=EC=A7=90=20+=20recompute=20=C2=B7=20[?= =?UTF-8?q?=EC=A0=81=EC=9A=A9]=20=EA=B7=A0=ED=98=95=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=20=3D=20=EB=8B=A8=EC=B6=94=20=EA=B0=92=20(421)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「종단 반영」 단추(기본 켬 · 다섯 갈래 · 누르면 바로 셈) · 요청에 with_profile 늘 실음 · 답 with_profile 먼저 - [재계산] = 입력 오류 · 셈 중 빼고 늘 켜짐 · recompute: true 보냄(서버 420 짝) - [적용] · [저장] 의 initial 에 with_profile · 다시 열 때 단추 되살림 · 옛 기록은 갈래로 - 맞추기 전 선(unsnapped) 화면에서 걷음 · 도움말 글(측점 맞춤 늘 · 종단 반영 · 재계산) - 시험 test_421_recommend_profile · 383_3 · 383_1 · 351_2 고쳐 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B05_Profile/B05_Profile_Api_RouteInitial.ts | 2 + .../B05_Profile_UI_RouteEdit_Initial.ts | 27 ++++-- .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 38 ++++---- .../B05_Profile_UI_RouteEdit_Recommend.ts | 60 ++++++------ ...B05_Profile_UI_RouteEdit_Recommend_Help.ts | 14 ++- .../B05_Profile_UI_Style_RouteRecommend.css | 2 +- .../tester/helper_421_profile_toggle.cjs | 91 +++++++++++++++++++ resources/tester/test_351_2_curve_stations.py | 17 +--- .../test_383_1_route_station_default.py | 1 - resources/tester/test_383_3_rerun_row.py | 8 +- .../tester/test_421_recommend_profile.py | 67 ++++++++++++++ 11 files changed, 253 insertions(+), 74 deletions(-) create mode 100644 resources/tester/helper_421_profile_toggle.cjs create mode 100644 resources/tester/test_421_recommend_profile.py diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index 2ddf78b19..b822fb7bb 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -42,6 +42,8 @@ export interface RouteInitialRequest { criteria?: RouteInitialCriteria; /** 추천 점수에 균형 종단(63-4)을 넣을지 — 끄면 원지반만. 안 보내면 켬(서버 기본). */ with_profile?: boolean; + /** [재계산] — 이 입력의 저장본을 지우고 새로 셈(421 · 서버 420). */ + recompute?: boolean; } /** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index 4eaf30210..5dae0ca4d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -28,13 +28,13 @@ import { import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; import { choiceLabel, + choiceWithProfile, DEFAULT_WEIGHTS, NO_WEIGHT_LOCKS, normalizeRouteInitial, readInitialChoice, sameBase, serverBase, - withProfileOf, writeInitialChoice, type CriteriaKey, type InitialBase, @@ -66,9 +66,10 @@ export interface InitialPanel { mode: RouteInitialLogic; weights: RouteInitialWeights | null; criteria: RouteInitialCriteria | null; + with_profile: boolean; }; - /** 지금 노선이 종단을 반영하는가 — 지금 추천 갈래가 정한다(예상노선 추적만 아님 · PLAN 77-2). - * 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 갈래대로). */ + /** 지금 노선이 종단을 반영하는가 — 지금 추천의 「종단 반영」 값(421 · 옛 기록이면 갈래로). + * 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 값대로). */ withProfile: () => boolean; /** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */ tag: () => InitialChoice | null; @@ -123,14 +124,16 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { weights: RouteInitialWeights | null, criteria: RouteInitialCriteria | null, withProfile: boolean, + recompute: boolean, ): Promise { const response = await fetchRouteInitial(projectId, { mode: logic, ...(weights ? { weights } : {}), ...(criteria ? { criteria } : {}), - ...(withProfile ? {} : { with_profile: false }), + with_profile: withProfile, + ...(recompute ? { recompute: true } : {}), }); - return normalizeRouteInitial(response, logic, weights, criteria); + return normalizeRouteInitial(response, logic, weights, criteria, withProfile); } recommendButton.addEventListener("click", () => { @@ -148,6 +151,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) }, weightMemory, logic: (choice ?? base).logic, + // 적용한 추천의 값 · 없으면 켬(421 — 옛 기록의 예상노선 추적도 켬에서 시작) + withProfile: choice?.withProfile ?? base.withProfile ?? true, planMinRadiusM: params.planMinRadiusM(), expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), current: params.planned().map(([x, y]) => [x, y]), @@ -169,8 +174,9 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { mode: picked.logic, weights: picked.weights, criteria: picked.criteriaOverrides, + with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420) }, - withProfileOf(picked.logic), + choiceWithProfile(picked), ); }, }); @@ -195,9 +201,14 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { current: () => { const now = choice ?? base; const criteria = choice ? choice.criteriaOverrides : base.criteria; - return { mode: now.logic, weights: now.weights, criteria }; + return { + mode: now.logic, + weights: now.weights, + criteria, + with_profile: choiceWithProfile(now), + }; }, - withProfile: () => withProfileOf((choice ?? base).logic), + withProfile: () => choiceWithProfile(choice ?? base), tag: () => choice, restoreTag(tag) { if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 213a3491a..daef238a2 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -27,9 +27,15 @@ export const LOGIC_LABEL: Record = { weighted: "비중 계산", }; -/** 종단을 늘 반영하는 갈래 — 예상노선 추적만 종단 없음(전처리 계획선 = 지반고 · PLAN 77-2). */ +/** 옛 꼴(종단 반영 값 없음)의 갈래별 종단 — 예상노선 추적만 종단 없음(PLAN 77-2). */ export const withProfileOf = (logic: RouteInitialLogic): boolean => logic !== "follow"; +/** 이 추천의 종단 반영 — 추천 창 「종단 반영」 단추 값(421) · 옛 캐시 · 옛 서버 답이면 갈래로. */ +export const choiceWithProfile = (choice: { + logic: RouteInitialLogic; + withProfile?: boolean | null; +}): boolean => choice.withProfile ?? withProfileOf(choice.logic); + /** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */ export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, grade: 25 }; export const WEIGHT_KEYS = ["expected", "earthwork", "grade"] as const; @@ -197,8 +203,8 @@ export interface InitialChoice { line?: Vertex[] | null; /** MC 가 일반측점 밖인 곡선 수(계획서 351-2) — 서버 `off_station_curves`. 옛 답이면 null. */ offStationCurves?: number | null; - /** 측점 맞춤 끔 = 맞추기 전 선(계획서 351-2) — 모달에만 · 캐시에는 안 적는다. 같으면 null. */ - unsnapped?: InitialChoice | null; + /** 추천 창 「종단 반영」 값(421) — [적용] 균형 종단 · 다시 열 때 단추. 옛 캐시면 없음. */ + withProfile?: boolean; } /** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */ @@ -292,6 +298,7 @@ export function normalizeRouteInitial( logic: RouteInitialLogic, weights: RouteInitialWeights | null, criteriaOverrides: RouteInitialCriteria | null = null, + withProfile?: boolean, ): InitialChoice { // 모양은 계획서 20 「API」 줄 — 노드 · 곡선은 `/route/plan` 과 같은 꼴이다. const body = (response ?? {}) as Record; @@ -339,15 +346,8 @@ export function normalizeRouteInitial( bandWidthM: num(body.band_width_m), stored: body.stored === true, offStationCurves: num(body.off_station_curves), - unsnapped: - body.unsnapped && typeof body.unsnapped === "object" - ? normalizeRouteInitial( - { ...body, ...(body.unsnapped as Record), unsnapped: null }, - logic, - weights, - criteriaOverrides, - ) - : null, + // 서버가 셈에 쓴 값이 먼저(420) · 옛 서버면 보낸 값 + withProfile: typeof body.with_profile === "boolean" ? body.with_profile : withProfile, balance: parseBalance(m.profile), line: Array.isArray(body.planned) ? (body.planned as unknown[][]).map((xy): Vertex => [Number(xy[0]), Number(xy[1])]) @@ -372,6 +372,8 @@ export interface InitialBase { weights: RouteInitialWeights | null; /** 사용자가 그때 화면 칸에 넣었던 제약 겹침만(비운 칸은 안 담김). */ criteria: RouteInitialCriteria | null; + /** [적용] 때 「종단 반영」(421 · 서버 `with_profile`) — 옛 기록이면 없음. */ + withProfile?: boolean; } /** 겹침 값 원시 객체를 편다 — 유한수인 칸만 담는다. 빈 결과는 null. */ @@ -399,7 +401,8 @@ export function serverBase(server: unknown): InitialBase { ? (body.logic as RouteInitialLogic) : "follow"; const criteria = parseCriteriaOverrides(body.criteria); - if (logic !== "weighted") return { logic, weights: null, criteria }; + const withProfile = typeof body.with_profile === "boolean" ? body.with_profile : undefined; + if (logic !== "weighted") return { logic, weights: null, criteria, withProfile }; const raw = (body.weights ?? {}) as Record; const weights = { expected: Math.round(Number(raw.expected)), @@ -407,7 +410,7 @@ export function serverBase(server: unknown): InitialBase { grade: Math.round(Number(raw.grade)), }; const valid = WEIGHT_KEYS.every((key) => Number.isFinite(weights[key])); - return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria }; + return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria, withProfile }; } /** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중 · 같은 제약 겹침인가. */ @@ -419,6 +422,9 @@ export function sameBase(choice: InitialChoice, base: InitialBase): boolean { ) { return false; } + // 서버가 종단 반영을 적어 둔 때만 견줌(옛 기록은 갈래 · 비중 · 제약만) + if (base.withProfile !== undefined && choiceWithProfile(choice) !== base.withProfile) + return false; return criteriaEqual(choice.criteriaOverrides, base.criteria); } @@ -576,10 +582,10 @@ export function readInitialChoice(projectId: string): InitialChoice | null { } export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void { - // 균형 종단 · 서버 선 · 맞추기 전 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다. + // 균형 종단 · 서버 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다. writeState( "routeedit-initial", - choice && { ...choice, balance: undefined, line: undefined, unsnapped: undefined }, + choice && { ...choice, balance: undefined, line: undefined }, projectId, ); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index abe1232d2..831ecfb20 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -3,7 +3,8 @@ * 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2 · 77) — 알약 다섯 한 줄(예상노선 추적 · 영선 · * 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 · * 못 지킨 구간 · 오른쪽 위 [적용] · [닫기]. - * 종단은 갈래가 정한다 — 예상노선 추적만 종단 없음, 나머지는 늘 균형 종단(`withProfileOf`). + * 종단은 「종단 반영」 단추가 정한다(421 · 기본 켬 · 다섯 갈래) — 누르면 바로 다시 셈 · [적용] 균형 종단도 이 값. + * [재계산]은 늘 켜짐(입력 오류 · 셈 중 빼고) — 서버 저장본을 지우고 새로 셈(`recompute`). * * 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 노선 편집의 노선은 * 건드리지 않는다 — [적용]을 누르면 `onApply` 로 바로 서버 저장(새 초기값 · 346-3), [닫기]는 @@ -40,7 +41,6 @@ import { violationsLine, violationText, WEIGHT_KEYS, - withProfileOf, type CriteriaKey, type InitialChoice, type InitialCriteria, @@ -67,7 +67,7 @@ type Vertex = [number, number]; const PILLS = ["follow", "gradeline", "earthwork", "gentle", "weighted"] as const; const PILL_TITLE: Record<(typeof PILLS)[number], string> = { - follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 · 종단 없음(계획고 = 지반고)", + follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 + 종단(종단 반영 끄면 계획고 = 지반고)", gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) + 종단", earthwork: "절토 · 성토가 가장 적은 노선 + 종단", gentle: "종단기울기가 가장 낮은 노선 + 종단", @@ -121,6 +121,8 @@ export interface RecommendParams { weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks }; /** 처음 고른 갈래 — 적용한 추천이 있으면 그 갈래(PLAN 77-8). */ logic: RouteInitialLogic; + /** 「종단 반영」 처음 값 — 적용한 추천의 값 · 없으면 켬(421). */ + withProfile: boolean; planMinRadiusM: number; /** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */ expected: Vertex[]; @@ -131,6 +133,7 @@ export interface RecommendParams { weights: RouteInitialWeights | null, criteria: RouteInitialCriteria | null, withProfile: boolean, + recompute: boolean, ) => Promise; /** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */ onApply: (choice: InitialChoice) => void; @@ -148,10 +151,8 @@ export function openRecommendModal(params: RecommendParams): void { /** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */ const invalid = new Set(); let preview: InitialChoice | null = null; - /** 측점 맞춤(계획서 351-2) — 켬 = 곡선 MC 를 일반측점에 맞춘 선 · 끔 = 맞추기 전 선. */ - let snapOn = true; - const shownChoice = (): InitialChoice | null => - preview && !snapOn ? (preview.unsnapped ?? preview) : preview; + /** 종단 반영(421) — 켬 = 후보마다 균형 종단 · 끔 = 계획고 = 지반고. */ + let profileOn = params.withProfile; let busy = false; /** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */ let dirty = false; @@ -307,13 +308,14 @@ export function openRecommendModal(params: RecommendParams): void { const pills = [...body.querySelectorAll("[data-logic]")]; const weightsBox = q(".b05-routerec__weights"); const rerun = q('[data-act="rerun"]'); - const snapToggle = toggleButton("측점 맞춤", snapOn, (active) => { - snapOn = active; - render(); + const profileToggle = toggleButton("종단 반영", profileOn, (active) => { + profileOn = active; + void run(); // 누르면 바로 — 저장본 있으면 그것 }); - snapToggle.dataset.act = "snap"; - snapToggle.title = "곡선 가운데(MC)를 일반측점(20 m) 위에 — 끄면 맞추기 전 선"; - rerun.before(snapToggle); + profileToggle.dataset.act = "profile"; + profileToggle.title = "켬 = 후보마다 측점 횡단으로 균형 종단 · 끔 = 계획고 = 지반고"; + rerun.title = "지금 칸 값으로 서버 저장본을 지우고 새로 셈"; + rerun.before(profileToggle); const stateEl = q("[data-state]"); const line = (name: string): HTMLElement => q(`[data-line="${name}"]`); const criteriaInputs = body.querySelectorAll("[data-criteria]"); @@ -345,9 +347,9 @@ export function openRecommendModal(params: RecommendParams): void { { kind: "expected", points: params.expected }, { kind: "current", points: params.current }, ]; - // 서버가 짠 원호 그대로(351-2 · 측점 맞춤 켬끔) — 옛 답이면 꺾임점 - const view = shownChoice(); - if (view) layers.push({ kind: "preview", points: view.line ?? view.snapshot.planned }); + // 서버가 짠 원호 그대로(351-2) — 옛 답이면 꺾임점 + if (preview) + layers.push({ kind: "preview", points: preview.line ?? preview.snapshot.planned }); map?.setLayers(layers); } @@ -397,9 +399,12 @@ export function openRecommendModal(params: RecommendParams): void { input.disabled = busy; }); q("[data-target-row]").hidden = logic !== "gradeline"; - // 측점 맞춤 오른쪽 · 늘 보임 — 바꾼 것이 있을 때만 켜짐(383-3) - rerun.disabled = busy || !dirty || invalid.size > 0; - const view = shownChoice(); + // 종단 반영 오른쪽 · 늘 켜짐(입력 오류 · 셈 중 빼고 · 421) + rerun.disabled = busy || invalid.size > 0; + profileToggle.disabled = busy; + profileToggle.setAttribute("aria-pressed", String(profileOn)); + profileToggle.classList.toggle("is-active", profileOn); + const view = preview; applyButton.disabled = busy || dirty || !view || invalid.size > 0; const shown = metricsLines(view?.metrics ?? null); line("metrics").textContent = view @@ -442,7 +447,8 @@ export function openRecommendModal(params: RecommendParams): void { drawMap(); } - async function run(): Promise { + /** `recompute` = [재계산] — 이 입력의 서버 저장본을 지우고 새로 셈. */ + async function run(recompute = false): Promise { if (logic === "weighted" && invalid.size > 0) { showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error"); return; @@ -450,13 +456,13 @@ export function openRecommendModal(params: RecommendParams): void { const mine = ++seq; const sentWeights = logic === "weighted" ? { ...weights } : null; const criteria = currentCriteria(); - const withProfile = withProfileOf(logic); + const withProfile = profileOn; busy = true; dirty = false; preview = null; // 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4). const started = Date.now(); - const label = choiceLabel(logic, sentWeights); + const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}`; /** 서버가 뒤에서 미리 셈 중이면 그 진행(364-4) — 2초마다 읽음. */ let behind = ""; const tick = (): void => { @@ -486,6 +492,7 @@ export function openRecommendModal(params: RecommendParams): void { sentWeights, Object.keys(criteria).length ? criteria : null, withProfile, + recompute, ); if (closed || mine !== seq) return; // 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다. @@ -497,7 +504,7 @@ export function openRecommendModal(params: RecommendParams): void { preview = choice; if (choice.balance) seen.set(label, choice.balance); else seen.delete(label); // 종단 없는 갈래(예상노선 추적) - stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)}${ + stateEl.textContent = `미리 보기: ${label}${ choice.stored ? " (저장된 결과)" : "" } — [적용]하면 바로 저장 · 새 초기값이 됩니다.`; } catch (error) { @@ -574,11 +581,10 @@ export function openRecommendModal(params: RecommendParams): void { render(); }); }); - rerun.addEventListener("click", () => void run()); + rerun.addEventListener("click", () => void run(true)); applyButton.addEventListener("click", () => { - const view = shownChoice(); // 보이는 쪽(측점 맞춤 켬끔) - if (!view) return; - params.onApply(view); + if (!preview) return; + params.onApply(preview); closed = true; close(); }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts index dfcdc918c..53ef1c562 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts @@ -30,12 +30,12 @@ const LOGIC_HELP: Record = { summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선", how: [ "새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음", - "전처리 초기 계획노선과 같은 규칙 — 「측점 맞춤」을 끄면 같은 선", + "전처리 초기 계획노선과 같은 규칙 · 곡선 가운데(MC)는 일반측점(20m) 위로 맞춤", "종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음", ], when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴", caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인", - profile: "없음 — 전처리 계획선처럼 계획고 = 지반고", + profile: "켬 = 균형 종단 · 끔 = 전처리 계획선처럼 계획고 = 지반고", }, gradeline: { summary: "정한 기울기로 등고선을 비스듬히 건너는 등경사선(영선) 노선", @@ -83,7 +83,7 @@ const LOGIC_HELP: Record = { const CRITERIA_HELP: Array<{ name: string; text: string }> = [ { name: "종단 반영", - text: "예상노선 추적을 뺀 네 갈래는 늘 반영. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [적용] 뒤 본 설계 값과 차이가 남. [적용]하면 이 종단이 그대로 첫 계획선이 되고 노선과 함께 바로 저장 · 새 초기값이 됨", + text: "단추로 켬 · 끔(기본 켬 · 다섯 갈래 모두) — 누르면 바로 다시 셈(저장본 있으면 그것). 끄면 계획고 = 지반고로 흙 양만 셈. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [적용] 뒤 본 설계 값과 차이가 남. [적용]하면 이 종단이 그대로 첫 계획선이 되고 노선과 함께 바로 저장 · 새 초기값이 됨", }, { name: "종단기울기 상한(%)", @@ -102,8 +102,12 @@ const CRITERIA_HELP: Array<{ name: string; text: string }> = [ text: "기울기를 잴 때 지형 잔굴곡을 고르는 폭. 클수록 매끈한 지반으로 봄. 비우면 5m", }, { - name: "측점 맞춤", - text: "켬(기본) = 곡선 가운데(MC)를 일반측점(20m) 위로 옮긴 선 — 교각점을 밀고 반지름을 맞추며, 같은 쪽으로 도는 이웃 곡선은 큰 곡선 하나로 합침(S자 사이 직선은 남김). 미는 거리 한도는 없고 탐색 복도 · 계류 이격 · 기울기 · 토공이 나빠지지 않는 자리만(예상노선 추적은 6m 안). 못 옮긴 곡선은 그 자리(「측점 밖 곡선 N개」). 비교값은 옮긴 선으로 다시 셈. 끔 = 옮기기 전 선. [적용]은 보이는 쪽", + name: "측점 맞춤(늘)", + text: "모든 갈래에서 곡선 가운데(MC)를 일반측점(20m) 위로 옮긴 선 — 교각점을 밀고 반지름을 맞추며, 같은 쪽으로 도는 이웃 곡선은 큰 곡선 하나로 합침(S자 사이 직선은 남김). 못 옮긴 곡선은 그 자리(「측점 밖 곡선 N개」). 비교값은 옮긴 선으로 셈", + }, + { + name: "재계산", + text: "지금 칸 값으로 서버 저장본을 지우고 새로 셈(덮어쓰기 아님) — 셈하는 동안 진행 줄이 보임. [적용]한 노선 · [초기화] 자리는 안 바뀜", }, ]; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css index 91ea17858..9b2a89327 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css @@ -409,7 +409,7 @@ align-self: flex-start; } -/* 측점 맞춤 · 재계산 한 줄(383-3) */ +/* 종단 반영 · 재계산 한 줄(421) */ .b05-routerec__row { display: flex; gap: var(--spacing-8); diff --git a/resources/tester/helper_421_profile_toggle.cjs b/resources/tester/helper_421_profile_toggle.cjs new file mode 100644 index 000000000..fc4a3f6bd --- /dev/null +++ b/resources/tester/helper_421_profile_toggle.cjs @@ -0,0 +1,91 @@ +/* 421 추천 창 「종단 반영」 셈 헬퍼 — `_Initial_Model` 을 그 자리에서 트랜스파일해 Node 로 돌려 + * 종단 반영 값이 서버 답 · 보낸 값 · 옛 캐시 · 서버 기록에서 어떻게 정해지는지 JSON 한 벌로. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + _map: map, + }; +} +const session = memoryStorage(); +globalThis.window = { localStorage: memoryStorage(), sessionStorage: session }; +globalThis.fetch = () => Promise.resolve({ ok: false }); + +const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } }; + +function loadTs(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 localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@ui/")) + return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts")); + +const answer = { + nodes: [ + { x: 0, y: 0 }, + { x: 10, y: 5 }, + ], + curves: [], +}; +const fromSent = model.normalizeRouteInitial(answer, "earthwork", null, null, false); +const fromServer = model.normalizeRouteInitial( + { ...answer, with_profile: true, unsnapped: { planned: [[0, 0]] } }, + "earthwork", + null, + null, + false, +); +const oldFollow = model.normalizeRouteInitial(answer, "follow", null); +const oldEarth = model.normalizeRouteInitial(answer, "earthwork", null); +const base = model.serverBase({ logic: "gentle", with_profile: false }); +const oldBase = model.serverBase({ logic: "gentle" }); +model.writeInitialChoice("p421", fromSent); +const readBack = model.readInitialChoice("p421"); + +process.stdout.write( + JSON.stringify({ + sent: fromSent.withProfile, + server: fromServer.withProfile, + serverHasUnsnapped: "unsnapped" in fromServer, + oldFollow: model.choiceWithProfile(oldFollow), + oldEarth: model.choiceWithProfile(oldEarth), + base: base.withProfile, + oldBase: oldBase.withProfile ?? null, + baseProfile: model.choiceWithProfile(base), + oldBaseProfile: model.choiceWithProfile(oldBase), + sameOff: model.sameBase({ ...fromSent, logic: "gentle" }, base), + sameOn: model.sameBase({ ...fromServer, logic: "gentle" }, base), + sameOld: model.sameBase({ ...fromServer, logic: "gentle" }, oldBase), + readBack: readBack && readBack.withProfile, + }), +); diff --git a/resources/tester/test_351_2_curve_stations.py b/resources/tester/test_351_2_curve_stations.py index a725dfe86..8c4771b8d 100644 --- a/resources/tester/test_351_2_curve_stations.py +++ b/resources/tester/test_351_2_curve_stations.py @@ -142,11 +142,6 @@ def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None: if mode != "follow": # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음) assert result["metrics"]["over_limit"]["total"] == 0 assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6 - # 측점 맞춤 끔 = 맞추기 전 선(같으면 None) — 비교값 따로 - raw = result["unsnapped"] - if raw is not None: - assert raw["planned"] != result["planned"] and raw["metrics"]["length_m"] > 0 - assert set(raw) == {"planned", "nodes", "curves", "off_station_curves", "metrics"} def _project_terrain(): @@ -171,7 +166,7 @@ def _project_terrain(): def test_project_modes_before_after() -> None: - """검증_sub1 실지형(14 % · R12) — 갈래별 측점 맞춤 전후 넘은 곳 · 최대 기울기 · 측점 밖 곡선.""" + """검증_sub1 실지형(14 % · R12) — 갈래별 맞춘 선의 넘은 곳 · 최대 기울기 · 측점 밖 곡선(421).""" if not (PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz").is_file(): pytest.skip("검증_sub1 storage 없음") terrain, streams = _project_terrain() @@ -189,17 +184,15 @@ def test_project_modes_before_after() -> None: min_radius_m=12.0, with_profile=False, ) - raw = result["unsnapped"] or result - after, before = result["metrics"], raw["metrics"] + after = result["metrics"] curves = len(result["curves"]) on_total += curves - result["off_station_curves"] curves_total += curves print( - f"{mode}: 넘은 곳 {before['over_limit']['total']} → {after['over_limit']['total']}" - f" · 최대 기울기 {before['max_grade_pct']:.1f} → {after['max_grade_pct']:.1f} %" - f" · 곡선 {len(raw['curves'])} → {curves}(측점 밖 {result['off_station_curves']})" + f"{mode}: 넘은 곳 {after['over_limit']['total']}" + f" · 최대 기울기 {after['max_grade_pct']:.1f} %" + f" · 곡선 {curves}(측점 밖 {result['off_station_curves']})" ) - assert raw["off_station_curves"] >= result["off_station_curves"] print(f"측점 위 곡선 {on_total}/{curves_total} = {100 * on_total / curves_total:.0f}%") assert on_total / curves_total >= 0.75 diff --git a/resources/tester/test_383_1_route_station_default.py b/resources/tester/test_383_1_route_station_default.py index 889991380..6e1c5775d 100644 --- a/resources/tester/test_383_1_route_station_default.py +++ b/resources/tester/test_383_1_route_station_default.py @@ -100,7 +100,6 @@ def test_follow_nodes_equal_initial(tmp_path: Path) -> None: assert all(abs(a[0] - b[0]) <= 1e-3 and abs(a[1] - b[1]) <= 1e-3 for a, b in zip(nodes, saved)) misses = [v for v in result["metrics"]["violations"] if v["kind"] == "station"] assert len(misses) == result["metrics"]["over_limit"]["station"] == result["off_station_curves"] - assert result["unsnapped"] is not None # 「측점 맞춤」 끔 = 맞추기 전 선 def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None: diff --git a/resources/tester/test_383_3_rerun_row.py b/resources/tester/test_383_3_rerun_row.py index b117b10c8..e67a07a16 100644 --- a/resources/tester/test_383_3_rerun_row.py +++ b/resources/tester/test_383_3_rerun_row.py @@ -1,4 +1,4 @@ -"""383-3 노선 추천 모달 [재계산] — 측점 맞춤 오른쪽 같은 줄 · 늘 보임 · 바꾼 것 있을 때만 켜짐. +"""383-3 노선 추천 모달 [재계산] — 종단 반영 오른쪽 같은 줄 · 늘 보임 · 늘 켜짐(421). ./venv/Scripts/python.exe -m pytest resources/tester/test_383_3_rerun_row.py -q """ @@ -10,11 +10,11 @@ ROOT = Path(__file__).resolve().parents[2] B05 = ROOT / "B05_Profile" -def test_재계산_한_줄_늘_보임_바꿨을_때만_켜짐() -> None: +def test_재계산_한_줄_늘_보임_늘_켜짐() -> None: ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8") assert re.search(r'
]*data-act="rerun">재계산', ts) assert 'data-act="rerun" hidden' not in ts and "rerun.hidden" not in ts - assert "rerun.before(snapToggle)" in ts # 토글이 같은 줄 왼쪽 - assert "rerun.disabled = busy || !dirty || invalid.size > 0;" in ts + assert "rerun.before(profileToggle)" in ts # 종단 반영 토글이 같은 줄 왼쪽(421) + assert "rerun.disabled = busy || invalid.size > 0;" in ts css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8") assert re.search(r"\.b05-routerec__row \{\s*display: flex;", css) diff --git a/resources/tester/test_421_recommend_profile.py b/resources/tester/test_421_recommend_profile.py new file mode 100644 index 000000000..605c8dee8 --- /dev/null +++ b/resources/tester/test_421_recommend_profile.py @@ -0,0 +1,67 @@ +"""421 추천 창 — 「측점 맞춤」 → 「종단 반영」 · [재계산] 늘 켜짐 + `recompute`. + +./venv/Scripts/python.exe -m pytest resources/tester/test_421_recommend_profile.py -q +""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" +HELPER = Path(__file__).with_name("helper_421_profile_toggle.cjs") + + +def _read(name: str) -> str: + return (B05 / name).read_text(encoding="utf-8") + + +def test_종단_반영_값_정하기() -> None: + out = json.loads( + subprocess.run( + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True + ).stdout + ) + assert out["sent"] is False # 옛 서버 = 보낸 값 + assert out["server"] is True # 서버 답 `with_profile` 이 먼저 + assert out["serverHasUnsnapped"] is False # 맞추기 전 선은 화면이 안 씀 + assert out["oldFollow"] is False and out["oldEarth"] is True # 옛 캐시 = 갈래로 + assert out["base"] is False and out["baseProfile"] is False + assert out["oldBase"] is None and out["oldBaseProfile"] is True + assert out["sameOff"] is True and out["sameOn"] is False and out["sameOld"] is True + assert out["readBack"] is False # 캐시에 남아 다시 열 때 되살림 + + +def test_창_단추와_재계산() -> None: + rec = _read("B05_Profile_UI_RouteEdit_Recommend.ts") + assert "측점 맞춤" not in rec and "snapOn" not in rec and "unsnapped" not in rec + assert 'toggleButton("종단 반영", profileOn,' in rec + assert "let profileOn = params.withProfile;" in rec + toggle = rec[rec.index('toggleButton("종단 반영"') :][:200] + assert "profileOn = active;" in toggle and "void run();" in toggle # 누르면 바로 + assert "rerun.before(profileToggle);" in rec + assert "rerun.disabled = busy || invalid.size > 0;" in rec # 늘 켜짐 + assert 'rerun.addEventListener("click", () => void run(true));' in rec + assert "const withProfile = profileOn;" in rec + assert "params.onApply(preview);" in rec + + +def test_요청과_적용에_종단_반영() -> None: + init = _read("B05_Profile_UI_RouteEdit_Initial.ts") + assert "with_profile: withProfile," in init + assert "...(recompute ? { recompute: true } : {})," in init + assert "withProfile: choice?.withProfile ?? base.withProfile ?? true," in init + on_apply = init[init.index("onApply: (picked) => {") :][:900] + assert "with_profile: choiceWithProfile(picked)," in on_apply + assert on_apply.count("choiceWithProfile(picked)") == 2 # 저장 칸 · 균형 종단 + assert "withProfileOf(" not in init + assert "with_profile: choiceWithProfile(now)" in init # [저장]에도 실음 + api = _read("B05_Profile_Api_RouteInitial.ts") + assert "recompute?: boolean;" in api + + +def test_도움말_글() -> None: + help_ts = _read("B05_Profile_UI_RouteEdit_Recommend_Help.ts") + assert "「측점 맞춤」을 끄면" not in help_ts and "끔 = 옮기기 전 선" not in help_ts + assert 'name: "재계산",' in help_ts and 'name: "측점 맞춤(늘)",' in help_ts + assert "단추로 켬 · 끔(기본 켬 · 다섯 갈래 모두)" in help_ts From 1db2f8f44873a47f0e29ecf9e048dba81ba138ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 12:43:15 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(B05=C2=B7B06=20=ED=95=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80):=20=ED=9A=A1=EB=8B=A8=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EC=85=88=20=EB=92=A4=20=EC=A2=8C=EC=B8=A1=20=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=20=ED=8F=BC=20=EC=9E=90=EB=8F=99=20=EC=B9=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EA=B7=B8=EB=A6=BC=20=E2=80=94=20=EA=B4=80?= =?UTF-8?q?=20=EC=98=AE=EA=B8=B4=20=EB=92=A4=20=EA=B4=80=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=3D=20B06=20=EC=B9=B4=EB=93=9C=20(416-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_shared crossDesignsUpdated 한 곳 — 두 보기 알림 + 좌측 시설 폼 recount(관 길이 · 흙막이 수) - 관을 옮긴 순간 셈한 옛 길이(6.9)가 횡단이 늦게 선 뒤에도 남던 것(캐시 6.6 · B06 카드 L=7 m (6.6)) - 시험 test_416_page_cache.py 한 칸 더 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_shared.ts | 11 +++++++++-- resources/tester/test_416_page_cache.py | 11 +++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index a04f8f8b0..d87638d8a 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -167,6 +167,13 @@ function toAll( )) as NonNullable; } +/** 횡단 설계가 다시 셈됨 — 두 보기에 알리고 좌측 시설 폼 자동 칸(관 길이 · 흙막이 수)을 공용 캐시 새 값으로 다시 + * 그림(416 · 관을 옮긴 뒤 측점 횡단이 늦게 서도 B05 좌측 = B06 카드). */ +function crossDesignsUpdated(shared: DesignPageShared, updated: ReadonlyArray): void { + toAll(shared, "onCrossDesignsUpdated")(updated); + shared.structures?.facility.recount(); +} + /** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */ export function sharedProfile(shared: DesignPageShared): DesignProfile { if (shared.profile) return shared.profile; @@ -183,7 +190,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { shared.lastPipes = pipes; toAll(shared, "onPipesChanged")(pipes); }, - onCrossDesignsUpdated: toAll(shared, "onCrossDesignsUpdated"), + onCrossDesignsUpdated: (updated) => crossDesignsUpdated(shared, updated), onStructureMove: toView(shared, "onStructureMove"), onStructureRemove: toView(shared, "onStructureRemove"), onPipeAdd: toView(shared, "onPipeAdd"), @@ -227,7 +234,7 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo if (!detailCross(detail, stations)) return null; const edits = profile.pipelineSource().edits; const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits }); - if (updated.length) toAll(shared, "onCrossDesignsUpdated")(updated); + if (updated.length) crossDesignsUpdated(shared, updated); profile.redraw(); return detailCross(detail, stations); }, diff --git a/resources/tester/test_416_page_cache.py b/resources/tester/test_416_page_cache.py index 86a602c3c..be83854a9 100644 --- a/resources/tester/test_416_page_cache.py +++ b/resources/tester/test_416_page_cache.py @@ -63,6 +63,17 @@ def test_M5_관_길이는_공용_캐시(r): ] +def test_M5_횡단_다시_셈_뒤_좌측_폼_다시_그림(): + shared = _read("A00_Common/design/design_shared.ts") + body = shared[shared.index("function crossDesignsUpdated(") :] + body = body[: body.index("\n}\n")] + assert "shared.structures?.facility.recount()" in body + # 종단도 알림 · 저장 노선 다시 셈 두 길 다 이 함수로 + assert "onCrossDesignsUpdated: (updated) => crossDesignsUpdated(shared, updated)" in shared + assert "if (updated.length) crossDesignsUpdated(shared, updated);" in shared + assert 'toAll(shared, "onCrossDesignsUpdated")' in body + + def test_M1_비운_뒤_두_보기는_새_객체_하나(r): assert r["m1"] == { "sameObject": True,