From 6a3f9647f3cb6ae4fb149feaf9f0ec732f1a5e2f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 18:19:21 +0900 Subject: [PATCH] =?UTF-8?q?refactor(387-4):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=EC=B0=BD=EA=B5=AC=20=ED=95=98?= =?UTF-8?q?=EB=82=98=20=C2=B7=20=EB=A9=B4=EC=A0=81=20=EC=96=B9=EA=B8=B0=20?= =?UTF-8?q?=ED=95=9C=20=EA=B3=B3=20=E2=80=94=20=ED=99=94=EB=A9=B4=EC=9D=80?= =?UTF-8?q?=20=EB=AC=B6=EC=9D=8C=EC=97=90=20=EB=B6=80=ED=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다시 셈(refreshCrossDesigns) 부르는 곳 = 묶음 cross 단계 하나(design_shared savedCross) - 종단 미리 셈 · B06 설계 맞춤 · 3D 업데이트 = pipelineCross · reconcilePagePipeline(▲ 한 번 = cross 한 번) - 입력 칸 밖 세션 고침(노견 · 소단 · 단면 유형 · 조정창)은 forceCross 로 같은 입력이어도 한 번 더 - 카드 그리기 · 횡단 보기 면적 쓰기 지움 · 다시 셈 창구가 면적을 늘 얹음(바뀐 측점만 알림) - 저장 안 한 조정창 값을 상세 가게가 읽을 때 얹음(새 Section_Adjusts · B05 만 열어도 같은 면적) - 새 test_384_4_cross_window · 옛 약속 시험 넷 새 약속으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- A00_Common/design/design_pipeline_cross.ts | 7 +- A00_Common/design/design_view_cross.ts | 29 ++---- B05_Profile/B05_Profile_UI_Corridor.ts | 6 +- B05_Profile/B05_Profile_UI_Profile_Preview.ts | 86 ++++-------------- B06_Section/B06_Section_Cross_Refresh.ts | 47 ++-------- B06_Section/B06_Section_Section_Adjusts.ts | 83 +++++++++++++++++ B06_Section/B06_Section_Section_Store.ts | 2 + B06_Section/B06_Section_Structure_Layouts.ts | 2 +- B06_Section/B06_Section_UI_Cross_View.ts | 6 +- .../B06_Section_UI_Cross_View_Frame.ts | 38 -------- .../B06_Section_UI_Page_Design_Sync.ts | 81 +++-------------- resources/tester/helper_384_4_adjusts.cjs | 90 +++++++++++++++++++ resources/tester/test_317_3d_update.py | 14 +-- resources/tester/test_342_1_station_line.py | 11 +-- resources/tester/test_384_4_cross_window.py | 86 ++++++++++++++++++ .../tester/test_b05_stale_design_sync.py | 5 +- .../tester/test_masshaul_single_source.py | 8 +- 17 files changed, 342 insertions(+), 259 deletions(-) create mode 100644 B06_Section/B06_Section_Section_Adjusts.ts create mode 100644 resources/tester/helper_384_4_adjusts.cjs create mode 100644 resources/tester/test_384_4_cross_window.py diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index 3cd50ce7a..416c40087 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -56,6 +56,11 @@ function digest(text: string): string { return `${text.length}:${hash >>> 0}`; } +/** 다시 셈 부탁 수(387-4) — 입력 칸에 없는 세션 고침(노견 · 소단 구간 · 조정창 4축 · 단면 유형)을 고친 화면이 + * 올림 · cross 입력 지문에 들어가 같은 입력이어도 묶음 cross 가 한 번 더 돎(다시 셈 창구 한 곳). */ +let forced = 0; +export const forceCross = (): void => void (forced += 1); + const finite = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; @@ -180,7 +185,7 @@ export function pipelineCrossSource( standard, byKey, rockBoundaryDefaultM: readRockBoundaryDefault(projectId), - specsKey: digest(JSON.stringify([...specs])), + specsKey: `${digest(JSON.stringify([...specs]))}|${forced}`, }, specs, }; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 8d9063801..c1ae3da63 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -67,10 +67,6 @@ import "../../B06_Section/B06_Section_UI_Style_Cross.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; -import { - applyStructureAreaRows, - structureAreaRows, -} from "../../B06_Section/B06_Section_Structure_Layouts"; import { createSampleWidener, L } from "../../B06_Section/B06_Section_UI_Page_Common"; import { createCrossViewPart, @@ -228,14 +224,7 @@ export async function mountCrossView( await new Promise((next) => setTimeout(next, 0)); // 겹친 신호(갈아끼움 · 묶음 · 초안) = 한 번(365-4) const fresh = await loadSectionDetail(projectId, currentRouteId); if (seq !== structuresRefreshSeq) return; - sectionDetail = fresh; - // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 셈(카드만 고치면 곡선이 옛 면적 · 2026-09-06 실측). - if (sectionDetail) { - applyStructureAreaRows( - sectionDetail.cross_sections, - structureAreaRows(sectionDetail.cross_sections), - ); - } + sectionDetail = fresh; // 구조물 면적은 상세 가게가 읽을 때 얹음(387-4 · 화면은 안 얹음) renderSectionDetail(); void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314) } catch (error) { @@ -292,7 +281,9 @@ export async function mountCrossView( const stationControls = createStationControls({ // 키는 등록표(`b_page_state`)가 만든다 — 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다. sessionKey: (kind) => stateKey(kind, projectId, currentRouteId), - refreshCard: (chainageM) => sectionView.refreshCard(chainageM), + // 조정창 값 = 입력 칸 밖 세션 고침 → 묶음 cross 한 번 더 · 면적은 거기서(387-4 · 카드 그리기는 안 고침) + refreshCard: (chainageM) => + void (sectionView.refreshCard(chainageM), reconcileStaleDesigns({ force: true })), detail: () => sectionDetail, crossHalfWidth, sampledHalfWidth, @@ -366,7 +357,8 @@ export async function mountCrossView( structureSpan: stationControls.structureSpan, soilTiers: pipeExtraCount, overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values), - refreshCard: (chainageM) => sectionView.refreshCard(chainageM), + refreshCard: (chainageM) => + void (sectionView.refreshCard(chainageM), reconcileStaleDesigns({ force: true })), }; // 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화). const structureSelection = wireStructureSelection( @@ -631,14 +623,7 @@ export async function mountCrossView( // 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다. sectionDetail = await loadSectionDetail(projectId, context.route_id); structuresPanel.facility.recount(); // 단 수는 횡단 자료로 셈 — 흙막이 상자 · 목록 램프 다시(54-15) - // 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면 - // 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측). - if (sectionDetail) { - applyStructureAreaRows( - sectionDetail.cross_sections, - structureAreaRows(sectionDetail.cross_sections), - ); - } + // 구조물 초안(저장 안 한 벽) · 그 면적은 상세 가게가 얹어 줌(387-4 · 화면은 안 얹음). // 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정 const summaryData = existing.longitudinal.data as { options?: { diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 46a4d4c73..38b31f5d8 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -20,7 +20,7 @@ import { writeCachedBytes, } from "../A00_Common/b_asset_cache"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import { refreshStaleDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; +import { reconcilePagePipeline } from "../A00_Common/design/design_pipeline_page"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client"; @@ -207,8 +207,8 @@ export async function refreshCorridor( rebuild = true, ): Promise { if (!routeId) return; - // [3D 업데이트] — 낡은 횡단 설계(계획고 · 구조물 자리 값)를 먼저 다시 셈한 뒤 그 설계로 지음(317-1) - if (rebuild) await refreshStaleDesigns({ projectId, routeId, detail }).catch(() => []); + // [3D 업데이트] — 계산 묶음 cross 가 다 돈 뒤(낡은 횡단 설계 다시 셈 · 387-4) 그 설계로 지음(317-1) + if (rebuild) await reconcilePagePipeline(projectId).catch(() => 0); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) .then((build) => viewer.setCorridor(withSections(build, detail))) .catch(() => viewer.setCorridor(null)); diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index 65b587ff7..06b7a969c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -2,20 +2,17 @@ * B05_Profile_UI_Profile_Preview.ts * 계획선 편집 → 횡단 설계 프리뷰 반영 (패널 본체에서 분리, 2026-09-02 · 700줄 한계). * - * 계획고가 바뀌면 측점별 횡단 단면적도 바뀐다 — 서버에 한 번 물어 전 측점을 다시 계산해 - * **공유 캐시가 들고 있는 같은 객체**를 제자리 갱신한다. 그래야 횡단 기준 유토곡선과 - * 3D 예상형상이 같은 값으로 따라온다(2026-08-03·08-23 사용자 보고). - * - * 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다. - * 노선 초안 동안은 서버에 안 물음 — 계산 묶음 cross 가 그 차례를 끝낼 때까지만 「다시 계산 중」 · 그 뒤 남은 - * 낡음은 곡선을 그리고 그 측점만 표시(342-1 · 349-0f). + * 계획고가 바뀌면 측점별 횡단 단면적도 바뀐다 — 다시 셈은 계산 묶음 cross 단계 **한 곳**(`design_shared` + * savedCross · 387-4)이 하고 여기서는 그 차례만 기다림(▲ 한 번 = cross 한 번). 예전에는 그리기가 낡음을 보면 + * 여기서도 따로 다시 셈 → 묶음과 두 번 셈. + * 기다리는 동안 「다시 계산 중」 · 묶음 차례가 cross 를 지났는데 남은 낡음은 곡선을 그리고 그 측점만 표시(342-1 · 349-0f). * ========================================================================== */ import type { AlignmentEdits, ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment"; -import { holdScreenAlignment, refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; +import { holdScreenAlignment } from "../B06_Section/B06_Section_Cross_Refresh"; import { staleChainages } from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; +import { pagePipeline, pipelineCross } from "../A00_Common/design/design_pipeline_page"; import { useDesignStore } from "../A00_Common/design/design_store"; import type { DesignPipeline, PipelineEvent } from "../A00_Common/design/logic/design_pipeline"; @@ -38,31 +35,24 @@ export interface CrossPreviewContext { } export interface CrossPreview { - /** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */ + /** 묶음에 cross 다시 부탁(같은 입력이면 안 셈). */ schedule: () => void; /** - * 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). - * 돌려주는 값 = 다시 셈을 기다리는 중인가. 다시 셈 뒤에도 남은 낡음은 false — 곡선은 그리고 - * 그 측점만 표시(342-1 · 09-03 「새 값만 보여주기」를 바꿈 · 사용자 확정 10-16). - * `draft` = 노선 초안 동안 — 서버에 안 묻고 묶음 cross 차례만 기다림(349-0f). + * 그리기가 잰 낡음 지문 — 돌려주는 값 = 묶음 cross 차례를 기다리는 중인가. 차례가 지난 뒤에도 남은 낡음은 + * false — 곡선은 그리고 그 측점만 표시(342-1 · 사용자 확정 10-16). `_draft` = 옛 갈래(노선 초안) · 같은 길. */ - scheduleIfStale: (key: string, draft?: boolean) => boolean; - /** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */ + scheduleIfStale: (key: string, _draft?: boolean) => boolean; + /** 패널을 걷을 때 기다림 타이머를 끈다. */ dispose: () => void; } export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { - let timer = 0; - let seq = 0; - let lastStale = ""; - /** 다시 셈 예약 · 진행 중 — 이 동안의 낡음은 「다시 계산 중」. */ - let busy = false; let warned = ""; const release = holdScreenAlignment((routeId) => routeId === ctx.routeId() ? ctx.alignment() : null, ); - /** 노선 초안 동안 기다리는 묶음 차례 — 그 차례 cross(또는 뒤 단계)가 오면 풂. */ + /** 기다리는 묶음 차례 — 그 차례 cross(또는 뒤 단계)가 오면 풂. */ let awaiting: number | null = null; let waitTimer = 0; let watched: DesignPipeline | null = null; @@ -91,58 +81,16 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { ); }; - const preview: CrossPreview = { - scheduleIfStale(key, draft = false) { - if (draft) { - if (awaiting === null) warnLeft(key); - return key !== "" && awaiting !== null; - } - if (key && key !== lastStale) preview.schedule(); - else if (!busy) warnLeft(key); - lastStale = key; - return key !== "" && busy; - }, - schedule() { - if (!ctx.detail() || ctx.routeId() === null) return; - busy = true; - window.clearTimeout(timer); - timer = window.setTimeout(() => { - const detail = ctx.detail(); - const routeId = ctx.routeId(); - if (!detail || routeId === null) return void (busy = false); - const current = (seq += 1); - // 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가 - // 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화). - const isCurrent = (): boolean => - current === seq && ctx.detail() === detail && ctx.routeId() === routeId; - void refreshCrossDesigns({ - projectId: ctx.projectId, - routeId, - detail, - edits: ctx.edits(), - alignment: ctx.alignment(), - // 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다. - shouldApply: isCurrent, - }) - .then((updated) => { - if (current === seq) busy = false; - // 남은 낡음이 있으면 바뀐 것이 없어도 다시 그림 — 「다시 계산 중」에 멈추지 않게(342-1) - if (updated.length || lastStale) ctx.onApplied(updated); - }) - .catch(() => { - /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ - if (current !== seq) return; - busy = false; - if (lastStale) ctx.onApplied([]); - }); - }, ctx.debounceMs()); + return { + scheduleIfStale(key) { + if (awaiting === null) warnLeft(key); + return key !== "" && awaiting !== null; }, + schedule: () => pipelineCross(ctx.projectId), dispose() { - window.clearTimeout(timer); window.clearTimeout(waitTimer); stopGeneration(); release(); }, }; - return preview; } diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 3b00ffc4d..673e6c6a0 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -54,7 +54,6 @@ import { import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile_UI_Profile_Data"; import { useStationKeys } from "./B06_Section_Station_Keys"; -import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; @@ -168,35 +167,10 @@ export function staleAgainstScreen(detail: SectionDetailResponse, routeId: numbe }); } -/** - * 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1). - * 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙). - */ -export async function refreshStaleDesigns( - input: Omit, -): Promise { - const draft = readAlignmentDraft(input.routeId); - if (!draft && !staleAgainstScreen(input.detail, input.routeId)) return []; - const saved = ( - input.detail.longitudinal.profile_alignment as - | { - edits?: { - station_offsets?: Record; - curve_radii?: Record; - }; - } - | undefined - )?.edits; - const edits = draft ?? { - station_offsets: saved?.station_offsets ?? {}, - curve_radii: saved?.curve_radii ?? {}, - }; - return refreshCrossDesigns({ ...input, edits }); -} - /** * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. - * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. + * 돌려주는 값은 설계 · 면적이 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. + * 부르는 곳은 계산 묶음 cross 단계 하나(`design_shared` savedCross · 387-4) — 화면은 `pipelineCross` 로 부탁. * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { @@ -206,16 +180,13 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise section.preview)) return []; useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3) const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); - const updated = refreshLocally(input) ?? (await refreshFromServer(input)); - // 구조물이 선 측점은 폐회로 면적이 정본이다 — 설계를 갈아 끼운 **이 자리에서 한 번** 다시 - // 얹는다. 부르는 쪽마다 얹으면 B06 흐름만 얹고 B05 는 안 얹어 두 보기의 유토 뱃지가 - // 갈렸다(계획서 24-2a · 실측 B05 −14,144.5 · B06 −12,533.2㎥). - if (updated.length) { - applyStructureAreaRows( - input.detail.cross_sections, - structureAreaRows(input.detail.cross_sections), - ); - } + if (!refreshLocally(input)) await refreshFromServer(input); + // 구조물이 선 측점은 폐회로 면적이 정본이다 — **이 자리 한 곳**에서 늘 다시 얹는다(387-4 · 카드 그리기는 + // 안 얹음 · 설계가 그대로여도 조정창 4축 · 구조물 제원이 바뀌면 면적이 바뀜 · 6 ms/67 측점). + applyStructureAreaRows( + input.detail.cross_sections, + structureAreaRows(input.detail.cross_sections), + ); return sections .filter((section) => JSON.stringify(section.design) !== before.get(section)) .map((section) => section.chainage_m); diff --git a/B06_Section/B06_Section_Section_Adjusts.ts b/B06_Section/B06_Section_Section_Adjusts.ts new file mode 100644 index 000000000..81559469d --- /dev/null +++ b/B06_Section/B06_Section_Section_Adjusts.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * B06_Section_Section_Adjusts.ts + * 저장 안 한 조정창 값(세션 · 측점 누가거리 열쇠)을 상세 설계 칸에 얹음 — 상세 가게가 읽을 때 한 번(387-4). + * + * 조정창(`_UI_Page_Station_Controls` · `_Ford_Controls` · `_Span_Control`)이 고칠 때 캐시에 얹는 것과 같은 + * 칸 · 같은 꼴. 예전에는 고칠 때만 얹어 새로고침 · B05 만 연 페이지는 세션 값을 몰랐음 — 카드 그리기가 면적을 + * 고쳐 들어 B06 을 연 뒤에만 면적이 바뀜(B05 만 연 면적 ≠ B06). 조정창은 세션 → 설계 순으로 읽으니 겹쳐도 같음. + * ========================================================================== */ + +import { readState } from "../A00_Common/b_page_state"; +import type { CrossDesign, SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import { fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom"; +import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; + +type Cells = Record; +type Obj = Record; +const INLET = ["auto", "revet", "I", "L", "U"]; +const isObj = (value: unknown): value is Obj => !!value && typeof value === "object"; +const count = (value: unknown): number => + Number.isInteger(value) && (value as number) > 0 ? (value as number) : 0; + +/** 조정창 읽기와 같은 꼴 — 옛 숫자 = x 이동량 · d 0 = 기본 자리(null). */ +function wallAdjust(value: unknown): WallAdjust | null { + if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value }; + if (!isObj(value)) return null; + const partial = value as Partial; + return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null }; +} + +export function withDraftAdjusts( + detail: SectionDetailResponse, + projectId: string, + routeId: number, +): void { + const read = (name: Parameters[0]): Cells => + readState(name, projectId, routeId) ?? {}; + const revet = read("revetx"); + const basin = read("basinadjust"); + const inlet = read("inletstruct"); + const extra = read("extrawall"); + const spans = read("extraspan"); + const ford = read("fordadjust"); + const box = read("boxadjust"); + const link = read("revetlink") as { detached?: Cells; grade?: Cells }; + for (const section of detail.cross_sections) { + const design = section.design as (CrossDesign & Obj) | null | undefined; + if (!design) continue; + const at = section.chainage_m.toFixed(2); + const own = (cells: Cells): Array<[string, unknown]> => + Object.entries(cells) + .filter(([key]) => key.startsWith(`${at}:`)) + .map(([key, value]) => [key.slice(at.length + 1), value]); + for (const [role, value] of own(revet)) { + const adjust = wallAdjust(value); + if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust }; + } + if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string)) + design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"]; + if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] }; + if (at in extra || `b${at}` in extra) + design.extra_wall_counts = { outlet: count(extra[at]), basin: count(extra[`b${at}`]) }; + for (const [wall, value] of own(spans)) { + const span = value as { lengthM: number; beforeM: number; afterM: number } | null; + if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue; + design.extra_spans = { + ...(design.extra_spans ?? {}), + [wall]: { length_m: span.lengthM, before_m: span.beforeM, after_m: span.afterM }, + }; + } + if (isObj(link.detached) && at in link.detached) + design.revet_link_detached = !!link.detached[at]; + if (isObj(link.grade) && at in link.grade) design.revet_follow_grade = !!link.grade[at]; + if (isObj(ford[at])) design.ford_adjust = fordAdjustOf(ford[at]); + const sides = box[at] as { left?: Obj; right?: Obj } | undefined; + if (isObj(sides)) + design.box_adjust = { + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right }, + }; + } +} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index ea766a1d2..562b38f8d 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -28,6 +28,7 @@ import { withDraftStations, } from "./B06_Section_Section_Drafts"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; +import { withDraftAdjusts } from "./B06_Section_Section_Adjusts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; @@ -71,6 +72,7 @@ async function withDraftWalls( await loadSavedPlusStations(projectId); await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2) await withDraftShapes(detail, projectId); + withDraftAdjusts(detail, projectId, routeId); // 저장 안 한 조정창 4축 · 구간 · 형식(387-4 · B05 만 열어도) return withStructureAreas(detail); } diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 037a325b2..abe2571dc 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -281,7 +281,7 @@ function areaRowOf( /** * 구조물이 선 측점의 절·성토 면적 — **카드를 그리지 않은 측점까지** 전부 낸다. - * 화면 그리기(`applyStructureAreas`)와 같은 계산이며, [저장]·[확정]과 초기값 산출 + * 다시 셈 창구 · 상세 가게(화면이 보는 면적 · 카드 그리기는 안 얹음 · 387-4)와 [저장]·[확정] · 초기값 산출 * (Node 진입점)이 이 한 벌을 함께 쓴다. */ export function structureAreaRows( diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 9b18571e5..1384ae987 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -42,7 +42,6 @@ import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies"; import { appendCardFrame, appendGroundProfile, - applyStructureAreas, drawnContentBounds, } from "./B06_Section_UI_Cross_View_Frame"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; @@ -507,10 +506,7 @@ export function createCrossSectionCard( fordLayout?.designTrim ?? boxLayout?.designTrim ?? ownDesignTrim; - // 구조물이 선 측점의 절·성토 면적 — **그려지는 폐회로**의 넓이로 고쳐 든다 - // (2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 것이 아니다). 그림과 면적이 - // 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다. - applyStructureAreas(section, designTrim); + // 구조물 측점 면적(그려지는 폐회로)은 다시 셈 창구 · 상세 가게가 같은 배치 셈으로 얹음 — 그리기는 안 고침(387-4). const designFrom = plotLayer.childElementCount; appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim, { layer: svg, diff --git a/B06_Section/B06_Section_UI_Cross_View_Frame.ts b/B06_Section/B06_Section_UI_Cross_View_Frame.ts index ca30247af..22e364a15 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Frame.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Frame.ts @@ -5,7 +5,6 @@ * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; -import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas"; import { CROSS_PAD, L, svgElement, svgText, validElevation } from "./B06_Section_UI_Section_Common"; type Sample = CrossSection["samples"][number]; @@ -88,43 +87,6 @@ export function appendCardFrame( ); } -/** - * 구조물이 만든 폐회로 면적을 측점 설계에 반영한다(2026-09-06 사용자 확정). - * - * 표준 설계선만 보는 기본 계산은 구조물이 있는지 모른다 — 기슭막이·세월교·BOX가 서면 - * 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그리므로 폐회로가 달라진다. - * 트림이 없으면(구조물 없는 측점) 아무것도 하지 않는다. - * - * 조작 중 즉시 반영용이다. 정본은 [저장]·[확정] 때 서버가 같은 코드 - * (`B06_Section_Structure_Areas_Node.ts`)로 다시 계산해 얹는다(2026-09-06). - */ -export function applyStructureAreas( - section: CrossSection, - trim: DesignTrim | undefined | null, -): void { - const design = section.design; - if (!trim || !design || !Array.isArray(design.design_line) || design.design_line.length < 2) { - return; - } - const ground = section.samples - .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") - .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) - .sort((a, b) => a.offset - b.offset); - const areas = computeStructureAreas({ - designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, - ground, - trim, - rockBoundaryOffsetM: - typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, - }); - if (!areas) return; - design.cut_area_m2 = Number(areas.cutAreaM2.toFixed(4)); - design.fill_area_m2 = Number(areas.fillAreaM2.toFixed(4)); - if (typeof design.cut_soil_area_m2 === "number") { - design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4)); - } -} - /** 원지반선 — 끊긴 샘플(무효 표고)마다 폴리선을 나눠 그린다. `toY` = 표고 → 화면 y(배율 반영). */ export function appendGroundProfile( plotLayer: SVGElement, diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 590837950..4598ef1da 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -4,7 +4,7 @@ * 동작은 그대로다(2026-09-13 분리). * - `handleDesignChange` : 버튼 선택 즉시 반영 + 세션 초안 기록 + 재계산 * - `recomputeIfRock` : 암 경계·암 절토 경사 변경 후 암 측점만 재계산 - * - `reconcileStaleDesigns`: 낡은 design 일괄 재계산(B05 와 같은 창구) + * - `reconcileStaleDesigns`: 횡단 다시 셈을 계산 묶음에 부탁(cross 단계 한 곳 · 387-4) * - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기 * - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영] * 고친 뒤 계산 묶음 cross 입력도 다시(`pipelineCross` · 노선 초안 동안 카드 = 묶음 새 노선 상세 · 365-2). @@ -15,21 +15,20 @@ import { writeCrossDesignChoice, writeShoulderExtend, } from "./B06_Section_Cross_Design_Session"; -import { showToast } from "@ui/ui_template_elements"; -import { refreshCrossDesigns, staleAgainstScreen } from "./B06_Section_Cross_Refresh"; import { bermSpansFromStructures, readBermSpans, writeBermSpans, } from "./B06_Section_UI_Page_Persist"; -import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { showToast } from "@ui/ui_template_elements"; import { L } from "./B06_Section_UI_Page_Common"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail"; import { useStructureIds } from "./B06_Section_Station_Keys"; -import { pipelineCross } from "../A00_Common/design/design_pipeline_page"; +import { pipelineCross, reconcilePagePipeline } from "../A00_Common/design/design_pipeline_page"; +import { forceCross } from "../A00_Common/design/design_pipeline_cross"; export interface DesignSyncContext { projectId: string | null; @@ -120,74 +119,22 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { await reconcileStaleDesigns({ force: true }); } - /** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 다시 셈 — 지금 값을 「사용자 선택」으로 + /** 암 경계 · 암 경사 변경 뒤 다시 셈(암 측점 2단계 무릎 · 단면적) — 지금 값을 「사용자 선택」으로 * 박지 않음(자동 판정이 굳으면 노선이 바뀌어도 재판정 안 됨 · 346-2). 암 경계는 세션에서 읽음. */ - function recomputeIfRock(chainageM: number): void { - const design = ctx - .detail() - ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01)?.design; - if (design && design.ground_type !== "soil") void reconcileStaleDesigns({ force: true }); - else if (projectId) pipelineCross(projectId); + function recomputeIfRock(_chainageM: number): void { + void reconcileStaleDesigns({ force: true }); } /** - * 로드 시 stale design을 최신 엔진·최신 종단 계획고로 자동 재계산한다(E-1 + N-6). - * 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이 - * 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선 - * (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API - * 호출이 없다. - * - * 재계산은 측점별 순차 호출이 아니라 **일괄 프리뷰 1회**로 한다(2026-08-04 사용자 확인 - * — 예전 for-await 루프는 stale 측점 수만큼 왕복하며 카드가 하나씩 바뀌어 "이력 재생"처럼 - * 보였고 제일 느렸다). 편집 델타는 B05 세션 초안이 있으면 그것을(두 화면 동일 계획선), - * 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형· - * 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다. + * 횡단 다시 셈 = 계산 묶음 cross 단계 **한 곳**(387-4) — 여기서는 묶음에 부탁하고 그 차례가 끝날 때까지 + * 기다림. 입력이 같으면 안 셈 · `force` = 입력 칸 밖 세션 고침(노견 · 소단 · 단면 유형 · 조정창)이라 한 번 더. + * 바뀐 카드는 묶음 알림(`onCrossDesignsUpdated`)이 다시 그림. */ async function reconcileStaleDesigns(options?: { force?: boolean }): Promise { - const sectionDetail = ctx.detail(); - const currentRouteId = ctx.routeId(); - if (!sectionDetail || !projectId || currentRouteId === null) return; - const draft = readAlignmentDraft(currentRouteId); - // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 + - // 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다. - // - // 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은 - // 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에 - // 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06 - // 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다. - // 판정 · 다시 셈 모두 종단도가 쥔 선(측점 한 벌) 기준 — 저장분 선끼리 견주면 화면과 갈림(342-1). - if (!options?.force && !draft && !staleAgainstScreen(sectionDetail, currentRouteId)) return; - // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 - // (CLAUDE.md 5장). - try { - const alignment = sectionDetail.longitudinal.profile_alignment as - | { - edits?: { - station_offsets?: Record; - curve_radii?: Record; - }; - } - | undefined; - const edits = draft ?? { - station_offsets: alignment?.edits?.station_offsets ?? {}, - curve_radii: alignment?.edits?.curve_radii ?? {}, - }; - // 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서 - // 다른 값이 된다(2026-09-03 사용자 지시로 일원화). - const updated = await refreshCrossDesigns({ - projectId, - routeId: currentRouteId, - detail: sectionDetail, - edits, - }); - // 카드는 한꺼번에 갈아 끼운다 — 측점마다 `refreshCard` 를 부르면 그때마다 종단 - // 그래프·유토곡선까지 다시 그려 측점 수만큼 화면이 멈췄다(2026-09-12). - ctx.view().refreshCards(updated); - } catch (error) { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${L("B06_Design_Failed")}${detail}`, "error"); - } - pipelineCross(projectId); // 세션 고침 · 표준 → 묶음(같은 입력이면 안 셈) + if (!projectId) return; + if (options?.force) forceCross(); + pipelineCross(projectId); + await reconcilePagePipeline(projectId); } /** diff --git a/resources/tester/helper_384_4_adjusts.cjs b/resources/tester/helper_384_4_adjusts.cjs new file mode 100644 index 000000000..9a4a46c6e --- /dev/null +++ b/resources/tester/helper_384_4_adjusts.cjs @@ -0,0 +1,90 @@ +/* 387-4 헬퍼 — 저장 안 한 조정창 값(세션)을 상세 가게가 얹는지(`B06_Section_Section_Adjusts`) · 얹은 뒤 구조물 + * 면적(`structureAreaRows`)이 그 값으로 바뀌는지 · 다른 노선 칸은 안 얹는지. 짝 = `test_384_4_cross_window.py`. + */ +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"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } }; + +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, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (STUBS[request]) return STUBS[request]; + 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, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +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 B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const { structureAreaRows } = loadTs(B06("B06_Section_Structure_Layouts")); +const { withDraftAdjusts } = loadTs(B06("B06_Section_Section_Adjusts")); +const { writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); + +const sections = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_186_1_pipe_292.json"), "utf8"), +).cross_sections; +const detail = { cross_sections: sections }; +const section = sections.find((s) => s.culvert); +const at = section.chainage_m.toFixed(2); +const area = () => { + const row = structureAreaRows(sections).find((r) => r.chainage_m === section.chainage_m); + return row ? [row.cut_area_m2, row.fill_area_m2] : null; +}; +const out = { before: area() }; +// 다른 노선 칸 — 안 얹음 +writeState("revetx", { [`${at}:outlet`]: { x: 3, d: null, h: null, m: null } }, "p", 8); +withDraftAdjusts(detail, "p", 7); +out.otherRoute = section.design.revet_adjust?.outlet ?? null; +// 이 노선 — 유출 벽 4축 · 유입 형식 · 다단 수 +writeState("revetx", { [`${at}:outlet`]: { x: 1.5, d: 0, h: null, m: null } }, "p", 7); +writeState("inletstruct", { [at]: "L" }, "p", 7); +writeState("extrawall", { [at]: 2 }, "p", 7); +withDraftAdjusts(detail, "p", 7); +out.laid = { + revet: section.design.revet_adjust.outlet, + inlet: section.design.inlet_structure, + extra: section.design.extra_wall_counts, +}; +out.after = area(); +withDraftAdjusts(detail, "p", 7); +out.again = area(); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_317_3d_update.py b/resources/tester/test_317_3d_update.py index 125f2a936..50bb87f6c 100644 --- a/resources/tester/test_317_3d_update.py +++ b/resources/tester/test_317_3d_update.py @@ -1,8 +1,8 @@ """PLAN 317-1 — B05 [3D 업데이트]가 낡은 횡단을 먼저 다시 셈(요구 213). - 낡음 지문(`staleKey`) — 낡은 측점이 있으면 지문 · 모두 맞으면 "" -- 그릴 때 잰 지문이 새것일 때만 다시 셈 예약(같은 낡음 되풀이 · 서버 폴백 반복 막음) -- 그래프가 그릴 때 지문을 패널에 넘김 · 단추는 예상형상을 짓기 전에 낡은 설계부터 다시 셈 +- 그릴 때 잰 낡음은 다시 셈을 직접 안 부름 — 계산 묶음 cross 한 곳이 셈(387-4 · ▲ 한 번 = cross 한 번) +- 그래프가 그릴 때 지문을 패널에 넘김 · 단추는 예상형상을 짓기 전에 묶음 차례(다시 셈)를 기다림 실제 TS 를 `helper_317_stale_preview.cjs` 가 Node 로 돌린다(다시 셈 창구는 흉내). """ @@ -47,9 +47,9 @@ def test_stale_key_marks_only_stale_sections(result: dict) -> None: assert result["key_fresh"] == "" -def test_same_stale_key_schedules_once(result: dict) -> None: - # a → 부름 · a(같음) → 안 부름 · "" → 안 부름(되돌림) · a(다시 낡음) → 부름 · b → 부름 - assert result["calls"] == [1, 1, 1, 2, 3] +def test_stale_key_never_refreshes_directly(result: dict) -> None: + # 낡음이 와도 미리 셈은 다시 셈 창구를 안 부름 — 묶음 cross 한 곳(387-4) + assert result["calls"] == [0, 0, 0, 0, 0] def test_draw_and_button_wiring() -> None: @@ -65,5 +65,5 @@ def test_draw_and_button_wiring() -> None: assert "pendingRecalc: pending," in render corridor = _read("B05_Profile", "B05_Profile_UI_Corridor.ts") body = corridor.split("export async function refreshCorridor", 1)[1].split("\n}\n", 1)[0] - assert "await refreshStaleDesigns({ projectId, routeId, detail })" in body - assert body.index("refreshStaleDesigns") < body.index("ensureCorridor") + assert "await reconcilePagePipeline(projectId)" in body + assert body.index("reconcilePagePipeline") < body.index("ensureCorridor") diff --git a/resources/tester/test_342_1_station_line.py b/resources/tester/test_342_1_station_line.py index 1dfd11def..75c6d63ed 100644 --- a/resources/tester/test_342_1_station_line.py +++ b/resources/tester/test_342_1_station_line.py @@ -99,11 +99,12 @@ def test_국부_연산_바뀐_측점만(out): def test_남은_낡음은_곡선을_그리고_경고(out): - assert out["settleFirst"] is True # 다시 셈 기다림 - assert out["settleSameKey"] is True # 다시 셈이 못 고침 - assert out["settleAfter"] is False # 기다림 끝 — 곡선 그림 + # 387-4 — 미리 셈은 다시 셈을 안 함(묶음 cross 한 곳) · 기다릴 묶음 차례가 없으면 곧바로 곡선 + 경고 + assert out["settleFirst"] is False + assert out["settleSameKey"] is True + assert out["settleAfter"] is False assert out["settleWarns"] == 1 - assert out["settleRedraws"] >= 1 # 바뀐 것 없어도 다시 그림 + assert out["settleRedraws"] == 0 assert out["settleClear"] is False @@ -116,7 +117,7 @@ def test_흙_배분_실패_열쇠_다시_보냄_받은_열쇠는_안_물음(out) def test_전체_측점_반영은_한_번_선택_안_박음(out): - assert out["applyAllCalls"] == [out["total"]] + assert out["applyAllCalls"] == [] # 387-4 — 화면이 직접 안 셈 · 묶음 cross 가 셈 assert out["applyAllChoiceKeys"] == 0 diff --git a/resources/tester/test_384_4_cross_window.py b/resources/tester/test_384_4_cross_window.py new file mode 100644 index 000000000..28ff5bce5 --- /dev/null +++ b/resources/tester/test_384_4_cross_window.py @@ -0,0 +1,86 @@ +"""387-4(384 단계 4) 횡단 다시 셈 창구 하나 · 면적 얹기 한 곳. + +· 다시 셈(`refreshCrossDesigns`) 부르는 곳 = 계산 묶음 cross 단계 하나(design_shared savedCross) — 종단 미리 + 셈 · B06 설계 맞춤 · 3D 업데이트는 묶음에 부탁(`pipelineCross` · `reconcilePagePipeline`) · ▲ 한 번 = cross 한 번 +· 화면(횡단 보기 · 카드 그리기)은 구조물 면적을 안 고침 — 다시 셈 창구 · 상세 가게만 얹음(카드 안 그려도 면적 같음) +· 저장 안 한 조정창 값(세션)은 상세 가게가 읽을 때 얹음 — B05 만 연 페이지도 같은 면적 · 다른 노선 칸은 안 얹음 + +./venv/Scripts/python.exe -m pytest resources/tester/test_384_4_cross_window.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_384_4_adjusts.cjs" + + +def _code(relative: str) -> str: + text = (ROOT / relative).read_text(encoding="utf-8") + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"(?m)^\s*//.*$|\s//\s.*$", "", text) + + +def _screen_and_page_ts() -> list[Path]: + files = list((ROOT / "A00_Common" / "design").rglob("*.ts")) + files += (ROOT / "B05_Profile").rglob("B05_Profile_*.ts") + files += (ROOT / "B06_Section").rglob("B06_Section_*.ts") + return files + + +def test_다시_셈_부르는_곳_하나() -> None: + callers = [ + str(p.relative_to(ROOT)) + for p in _screen_and_page_ts() + for _ in re.findall(r"(? None: + hits = [ + str(p.relative_to(ROOT)) + for p in _screen_and_page_ts() + if ("_UI_" in p.name or p.name.startswith("design_view")) + and re.search(r"applyStructureArea\w*\(", _code(str(p.relative_to(ROOT)))) + ] + assert hits == [] + + +def test_화면은_묶음에_부탁() -> None: + sync = _code("B06_Section/B06_Section_UI_Page_Design_Sync.ts") + assert "pipelineCross(projectId)" in sync and "await reconcilePagePipeline(projectId)" in sync + preview = _code("B05_Profile/B05_Profile_UI_Profile_Preview.ts") + assert "pipelineCross(ctx.projectId)" in preview + corridor = _code("B05_Profile/B05_Profile_UI_Corridor.ts") + assert "await reconcilePagePipeline(projectId)" in corridor + # 입력 칸 밖 세션 고침(노견 · 소단 · 조정창)은 「한 번 더」 표시로 같은 입력이어도 cross 다시 + cross = _code("A00_Common/design/design_pipeline_cross.ts") + assert "export const forceCross" in cross and "|${forced}`" in cross + + +@pytest.fixture(scope="module") +def adjusts() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_세션_조정값을_상세_가게가_얹음(adjusts: dict) -> None: + assert adjusts["otherRoute"]["x"] != 3 # 다른 노선 칸은 안 얹음 + assert adjusts["laid"] == { + "revet": {"x": 1.5, "d": None, "h": None, "m": None}, # d 0 = 기본 자리(조정창 읽기와 같음) + "inlet": "L", + "extra": {"outlet": 2, "basin": 0}, + } + # 얹은 값으로 구조물 면적이 바뀜 · 두 번 얹어도 같음 + assert adjusts["after"] != adjusts["before"] + assert adjusts["again"] == adjusts["after"] diff --git a/resources/tester/test_b05_stale_design_sync.py b/resources/tester/test_b05_stale_design_sync.py index d9527e41b..9e95630fa 100644 --- a/resources/tester/test_b05_stale_design_sync.py +++ b/resources/tester/test_b05_stale_design_sync.py @@ -99,7 +99,8 @@ def test_source_shared_rule_and_b05_entry_recompute(): assert "scheduleCrossPreview();" in B05_PANEL # B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다 # (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다). - # 판정은 종단도가 쥔 화면 선 기준(342-1 · 공용 규칙 hasStaleDesigns 를 그 선으로 부름) - assert "staleAgainstScreen(sectionDetail, currentRouteId)" in B06_PAGE + # 다시 셈은 계산 묶음 cross 한 곳(387-4) — B06 은 판정 · 다시 셈 없이 묶음에 부탁 + assert "pipelineCross(projectId)" in B06_PAGE + assert "staleAgainstScreen" not in B06_PAGE assert "two_stage_slope === undefined" in COMMON assert "two_stage_slope === undefined" not in B06_PAGE diff --git a/resources/tester/test_masshaul_single_source.py b/resources/tester/test_masshaul_single_source.py index ccc119e31..55f08026f 100644 --- a/resources/tester/test_masshaul_single_source.py +++ b/resources/tester/test_masshaul_single_source.py @@ -47,8 +47,11 @@ def test_cross_refresh_is_single_entry(): assert "export async function refreshCrossDesigns" in REFRESH assert "readStandardCrossSession(projectId)" in REFRESH assert "readRockBoundarySession(projectId, routeId)" in REFRESH + # 부르는 곳 = 계산 묶음 cross 단계 하나(design_shared savedCross · 387-4) — 화면은 묶음에 부탁 + shared = _read("A00_Common/design/design_shared.ts") + assert shared.count("refreshCrossDesigns(") == 1 for source in (B05_PREVIEW, B06_PAGE): - assert "refreshCrossDesigns(" in source + assert "refreshCrossDesigns(" not in source assert "previewCrossDesigns(" not in source @@ -75,3 +78,6 @@ def test_cross_refresh_lays_structure_areas_once(): body = body[: body.index("\n}\n")] assert "applyStructureAreaRows(" in body and "structureAreaRows(" in body assert "applyStructureAreaRows(" not in B05_PREVIEW + # 화면(횡단 보기 · 카드 그리기)은 면적을 안 고침 — 카드 안 그려도 면적 같음(387-4) + assert "applyStructureAreaRows(" not in B06_PAGE + assert "applyStructureAreas" not in _read("B06_Section/B06_Section_UI_Cross_View.ts")