From d9fec937aa9ffb02e6f4e8479beeb782488b1316 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 13:14:59 +0900 Subject: [PATCH] =?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=8B=9C=EC=84=A4=20=EC=98=AE=EA=B9=80?= =?UTF-8?q?=20=3D=20=ED=9A=A1=EB=8B=A8=20=ED=8E=B8=EC=A7=91=20=EC=97=B4?= =?UTF-8?q?=EC=87=A0=EC=A7=B8=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20=EC=A4=91?= =?UTF-8?q?=20=EB=A8=B9=EC=9D=B4=EA=B8=B0=20=EB=A9=88=EC=B6=A4=20=C2=B7=20?= =?UTF-8?q?=EC=83=88=20=EC=8B=9C=EC=84=A4=20=EC=B8=A1=EC=A0=90=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20=EB=92=A4=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EA=B2=AC=EC=A4=8C=20(415-1=20=C2=B7=20415-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H1: 관 · 구조물 밖 측점이 움직인 때만 「노선 바뀜」 넘김 규칙 · 시설만 옮기면 열쇠째 옮김 (사면 쉬프트 · 소단 깊이 · 단면 유형 · 관 측점 고침 그대로) - H1 덧: 초안 없을 때도 저장분 계획으로 열쇠 주인(관 = 저장 id · 계산 묶음 열쇠와 같음 · 첫 옮김도 열쇠째) · 묶음이 세운 열쇠 목록을 같은 측점이면 주인 규칙으로 다시 세지 않음 · 갈아끼운 옛 상세 맞춤 끝냄 - H2: [저장] 도중 관 · 구조물 먹이기 · 맞춤 고리 쉼 · 끝나면 저장 목록 다시 읽고 맞춤(design_pipeline_facilities) - M4: 새 시설 측점 서버 미리보기(preview-stations) 걷음 · 새 자리 = 계산 묶음 갈아끼움 · 카드 이름만 얹음 · 종류 바뀐 자리 = 구조물 없는 사본 - 415-2: 되돌리기 · 다시하기(세션만 되씀) 뒤 관 · 구조물을 묶음 입력과 견줘 다시 넣음 - 시험: test_415_page_split 새로 · 미리보기 전제 시험(273-2 · 298 · 317 · 318 · 322-0 · 322-3)을 새 규칙으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- .../design/design_pipeline_facilities.ts | 72 +++++ A00_Common/design/design_pipeline_page.ts | 25 +- .../B06_Section_Api_Culvert_Options.ts | 2 - B06_Section/B06_Section_Section_Drafts.ts | 107 +++---- B06_Section/B06_Section_Section_Store.ts | 2 + B06_Section/B06_Section_Station_Keys.ts | 16 +- B06_Section/logic/B06_Section_Station_Swap.ts | 10 +- .../tester/helper_273_2_preview_stations.cjs | 14 +- resources/tester/helper_318_cross_card.cjs | 15 +- resources/tester/helper_322_0_golden.cjs | 23 +- resources/tester/helper_322_3_flow.cjs | 19 ++ resources/tester/helper_415_h2.cjs | 278 ++++++++++++++++++ resources/tester/helper_415_swap.mjs | 149 ++++++++++ .../tester/test_273_2_preview_stations.py | 21 +- resources/tester/test_298_add_reanalyze.py | 2 +- .../tester/test_317_kind_change_station.py | 5 +- resources/tester/test_318_cross_card.py | 6 +- resources/tester/test_322_3_flow.py | 12 +- resources/tester/test_415_page_split.py | 152 ++++++++++ 19 files changed, 816 insertions(+), 114 deletions(-) create mode 100644 A00_Common/design/design_pipeline_facilities.ts create mode 100644 resources/tester/helper_415_h2.cjs create mode 100644 resources/tester/helper_415_swap.mjs create mode 100644 resources/tester/test_415_page_split.py diff --git a/A00_Common/design/design_pipeline_facilities.ts b/A00_Common/design/design_pipeline_facilities.ts new file mode 100644 index 000000000..7df48aef7 --- /dev/null +++ b/A00_Common/design/design_pipeline_facilities.ts @@ -0,0 +1,72 @@ +/* ============================================================================= + * A00_Common/design/design_pipeline_facilities.ts + * 계산 묶음 관 · 구조물 먹이기(PLAN 415) — 가게 관 · 구조물 초안이 바뀌면 묶음에 넣음(비면 저장분). + * · [저장] 도중 = 안 넣음(H2) — 초안이 비는 사이 열 때 읽은 옛 저장분으로 돌지 않게 · 끝나면 `resync` + * (저장 목록 다시 읽기 · 입력 맞춤) 한 번. + * · 되돌리기 · 다시하기 = 세션만 되쓰고 가게 알림이 없음 → 묶음 입력과 견줘 다르면 넣음(415-2 · 노선 초안은 + * 노선 길이 맡음). + * 저장 창구는 늦게 불러옴(묶음 시험이 화면 모듈을 안 끌게). + * ========================================================================== */ + +import { onPageHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History"; +import type { useDesignStore } from "./design_store"; +import type { ChangeSource } from "./logic/design_pipeline_types"; + +type Key = "pipes" | "structures"; +const FACILITIES: ReadonlyArray<{ source: ChangeSource; key: Key }> = [ + { source: "pipe", key: "pipes" }, + { source: "structure", key: "structures" }, +]; + +export interface FacilityFeed { + saving(): boolean; + /** 저장이 끝나면 `resync` 한 번 — 기다리는 동안 다시 불러도 한 번. */ + afterSave(): void; + stop(): void; +} + +export function feedFacilities(o: { + projectId: string; + store: ReturnType; + /** 묶음이 받을 때인가(열었음 · 묶음이 가게에 쓰는 메아리 아님). */ + ready: () => boolean; + gone: () => boolean; + saved: (key: Key) => unknown; + input: (key: Key) => unknown; + /** 노선 초안이 걸렸나 — 되돌리기는 노선 길이 맡음. */ + routed: () => boolean; + feed: (source: ChangeSource, value: unknown) => void; + resync: () => Promise; +}): FacilityFeed { + let savingOf: ((id: string) => boolean) | null = null; + // 한 번 불러 봄 — 덜 선 모듈(시험 싣개의 고리)은 안 받음 + void import("./design_save") + .then((m) => (m.savingDesign(o.projectId), (savingOf = m.savingDesign))) + .catch(() => undefined); + const saving = (): boolean => savingOf?.(o.projectId) ?? false; + let waiting: Promise | null = null; + const afterSave = (): void => { + waiting ??= (async () => { + // ponytail: 100 ms 마다 물음 — 저장 끝 알림이 생기면 그것으로 + while (saving() && !o.gone()) await new Promise((done) => setTimeout(done, 100)); + if (!o.gone()) await o.resync(); + })().finally(() => (waiting = null)); + }; + const put = (source: ChangeSource, value: unknown): void => + saving() ? afterSave() : o.feed(source, value); + const stops = [ + ...FACILITIES.map(({ source, key }) => + o.store.subscribe(key, (value) => o.ready() && put(source, value ?? o.saved(key))), + ), + onPageHistory({ + restore: () => { + if (!o.ready() || o.routed()) return; + for (const { source, key } of FACILITIES) { + const now = o.store.get(key) ?? o.saved(key); + if (JSON.stringify(now) !== JSON.stringify(o.input(key))) put(source, now); + } + }, + }), + ]; + return { saving, afterSave, stop: () => stops.forEach((stop) => stop()) }; +} diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index a93da27c5..94759324e 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -66,8 +66,9 @@ import { } from "../../B05_Profile/B05_Profile_Api_Replan"; import { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; -import { pagePipes, pageStructures, trackPageBusy } from "./design_page_data"; +import { forgetPageData, pagePipes, pageStructures, trackPageBusy } from "./design_page_data"; import type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types"; +import { feedFacilities } from "./design_pipeline_facilities"; export type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types"; import { groundStageDeps, @@ -569,6 +570,7 @@ function createPagePipeline( while (seen !== lastRun) await (seen = lastRun); // 기다리는 사이 뜬 차례까지 await pipe.settled(); if (disposed || canon?.routeId !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫 + if (facilities.saving()) return (facilities.afterSave(), round); // 저장 도중 = 옛 저장분 · 끝나면 다시 const now = inputsNow(); const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>) .filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key])) @@ -617,13 +619,18 @@ function createPagePipeline( }) .catch(() => undefined); - const unsubscribe = [ - store.subscribe("pipes", (pipes) => started && !echo && void feed("pipe", pipes ?? savedPipes)), - store.subscribe( - "structures", - (list) => started && !echo && void feed("structure", list ?? savedStructures), - ), - ]; + /** 관 · 구조물 먹이기 — [저장] 도중 멈춤(끝나면 저장 목록 다시 읽고 맞춤) · 되돌리기 뒤 다시 견줌(415). */ + const facilities = feedFacilities({ + projectId, + store, + ready: () => started && !echo, + gone: () => disposed, + saved: (key) => (key === "pipes" ? savedPipes : savedStructures), + input: (key) => pipe.input(key), + routed: () => pipe.input("route") !== null || store.get("routeDraft") !== null, + feed: (source, value) => void feed(source, value), + resync: async () => (forgetPageData(projectId), await loadPlan(), reconcile()), + }); const bridge: RouteDraftBridge = { saved: () => store.get("routeDraft"), @@ -679,7 +686,7 @@ function createPagePipeline( detail: () => shown, dispose() { disposed = true; - unsubscribe.forEach((stop) => stop()); + facilities.stop(); pipe.dispose(); }, }; diff --git a/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index 5464eadfb..89bd7be8c 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -30,7 +30,6 @@ import { type StateName, } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; -import { forgetPreviewAdjusts } from "./B06_Section_Section_Drafts"; /** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로. * 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */ @@ -167,7 +166,6 @@ export function forgetStationAdjusts( if (hit !== undefined) delete marks[hit]; if (next !== first) marks[chainageM.toFixed(2)] = first; writeState("kindreset", Object.keys(marks).length ? marks : null, projectId); - forgetPreviewAdjusts(projectId, chainageM, STRUCTURE_ADJUST_KEYS); reloadStationAdjusts?.(); } diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 856a372a5..4f36cdef8 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -4,8 +4,7 @@ * * · 옛 자리 구조물 측점(서버가 [저장] 때 다시 맞추기 전) = 숨김 — 쥐고 있다가 초안이 다시 그 자리를 * 심거나 초안이 없어지면 돌려 놓음. - * · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB - * 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음. + * · 새 자리(서버 횡단 없음) = 계산 묶음 측점 갈아끼움이 얹음(브라우저 계산 줄 하나 · 415 M4) — 여기는 안 물음. * * 심는 자리 · 카드 이름 = 측점 계획 한 벌(`planStations` · 서버 `resolve_extra_stations` 짝 · 322-3) · * 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` → `attachShapes` · 서버 `attach_shapes` 짝 · 323-4). 일반 측점 @@ -31,11 +30,7 @@ import { type PlannedStation, } from "../common_util/common_util_structure_station"; import { useStationKeys, useStationOwners } from "./B06_Section_Station_Keys"; -import { - previewStructureSections, - type CrossSection, - type SectionDetailResponse, -} from "./B06_Section_Api_Fetch"; +import { type CrossSection, type SectionDetailResponse } from "./B06_Section_Api_Fetch"; type Pipes = PendingPipes; @@ -43,35 +38,19 @@ const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_STATION_M const byChainage = (a: { chainage_m: number }, b: { chainage_m: number }) => a.chainage_m - b.chainage_m; -/** 미리 뜬 횡단(`프로젝트:노선:누가거리`) — 못 뜬 자리는 null(다시 안 물음) · 저장 뒤 비움. */ -const previews = new Map(); /** 초안이 빼 둔 옛 자리 측점. */ const parked = new WeakMap(); /** 상세마다 마지막 맞춤 번호 — 늦게 끝난 옛 맞춤이 새 맞춤을 덮지 않게. */ const turns = new WeakMap(); -/** 저장 · 노선 바뀜 뒤 — 페이지 저장분(자료 줄 · 386-2) · 미리 뜬 횡단을 버림. */ -export function forgetDraftStations(projectId: string): void { - forgetPageData(projectId); - for (const key of [...previews.keys()]) if (key.startsWith(`${projectId}:`)) previews.delete(key); +/** 상세가 갈아끼워짐 — 옛 상세에 돌던 맞춤은 측점 열쇠 목록을 옛 측점으로 덮지 않게 끝냄(415 H1). */ +export function supersedeDraftStations(detail: SectionDetailResponse): void { + turns.set(detail, (turns.get(detail) ?? 0) + 1); } -/** 종류 바꿈(318-4) — 그 자리 미리 뜬 횡단 설계에 얹혔던 조정값 사본을 걷음(조작이 캐시에 쓴 것). */ -export function forgetPreviewAdjusts( - projectId: string, - chainageM: number, - keys: readonly string[], -): void { - for (const [key, section] of previews) { - if ( - !key.startsWith(`${projectId}:`) || - !section?.design || - !near(section.chainage_m, chainageM) - ) - continue; - const design = section.design as unknown as Record; - for (const name of keys) delete design[name]; - } +/** 저장 · 노선 바뀜 뒤 — 페이지 저장분(자료 줄 · 386-2)을 버림. */ +export function forgetDraftStations(projectId: string): void { + forgetPageData(projectId); } /** 저장된 관 · 구조물 — 한쪽 초안만 있을 때 심는 자리 셈에 씀(페이지 자료 줄 한 번 · 저장 뒤 다시). */ @@ -81,20 +60,6 @@ const savedOf = (projectId: string): Promise<[Pipes, StructureInstance[]]> => pageStructures(projectId).then((res) => res.structures), ]); -async function previewOf( - projectId: string, - routeId: number, - chainages: number[], -): Promise { - const key = (at: number) => `${projectId}:${routeId}:${at.toFixed(3)}`; - const ask = chainages.filter((at) => !previews.has(key(at))); - if (ask.length) { - const got = await previewStructureSections(projectId, ask); - ask.forEach((at) => previews.set(key(at), got.find((s) => near(s.chainage_m, at)) ?? null)); - } - return chainages.flatMap((at) => previews.get(key(at)) ?? []); -} - /** 일반 측점 자리(grid · 272)의 「구조물 없는 꼴」(217) — 저장분에서 시설 · 이름만 뺀 사본(한 벌을 다시 씀 · * 지형 쪽 고친 값은 그 측점 것 그대로) · 새 종류는 그 위에 얹음(`decorate`) · 측점은 남으므로 숨기지 않음. */ const bares = new WeakMap(); @@ -109,7 +74,7 @@ function bareOf(section: CrossSection): CrossSection { return copy; } -/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름(그림 자료는 `withDraftShapes` 가 모든 측점에 한 번). */ +/** 구조물 없는 사본에 카드 이름(그림 자료는 `withDraftShapes` 가 모든 측점에 한 번). */ function decorate(fresh: CrossSection[], planned: readonly PlannedStation[]): void { for (const section of fresh) { section.preview = true; @@ -156,9 +121,8 @@ export async function withDraftShapes( } /** - * 초안에 맞춰 상세의 횡단 목록을 고친다 — 옛 자리 숨김 · 새 자리 미리 뜬 횡단 얹음. 초안이 없으면 - * 서버 목록 그대로(빼 둔 것 돌려 놓음). 미리 뜨기를 못 하면(샘플링 조건 없음 · 끊김) 새 자리는 - * 종전처럼 [저장] 뒤에 섬. + * 초안에 맞춰 상세의 횡단 목록을 고친다 — 옛 자리 숨김 · 종류 바뀐 자리 구조물 없는 사본. 초안이 없으면 + * 서버 목록 그대로(빼 둔 것 돌려 놓음). 새 자리는 계산 묶음 측점 갈아끼움이 얹음(415 M4). */ export async function withDraftStations( detail: SectionDetailResponse, @@ -175,9 +139,19 @@ export async function withDraftStations( ].sort(byChainage); let live = all; let stale: CrossSection[] = []; - let fresh: CrossSection[] = []; // 추가 측점 초안(368-2)도 측점 계획을 바꿈 — 계획은 그 초안을 스스로 읽음 - if (pipesDraft || structuresDraft || readPlusDraft(projectId)) { + if (!(pipesDraft || structuresDraft || readPlusDraft(projectId))) { + // 초안 없음 = 저장분 계획으로 열쇠 주인만 — 관 = 저장 id(계산 묶음 열쇠와 같음 · 첫 옮김도 열쇠째 · 415 H1) + const inputs = await statusOf(projectId, null, null); + if (inputs) { + const [pipes, structures, types] = inputs; + useStationOwners( + projectId, + planStations(pipes, structures, types, registryDefaults(types)), + pipes, + ); + } + } else { const inputs = await statusOf(projectId, pipesDraft, structuresDraft); if (inputs) { const [pipes, structures, types] = inputs; @@ -185,7 +159,7 @@ export async function withDraftStations( useStationOwners(projectId, planned, pipes); // 측점 열쇠 = 덮는 구조물 id(344-3) · 관 = pipe: const planted = planned.map((station) => station.chainage_m); // 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고 - // 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴 + // 구조물 없는 사본을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴 const [oldPipes, oldStructures] = await savedOf(projectId).catch( (): [Pipes, StructureInstance[]] => [[], []], ); @@ -221,25 +195,28 @@ export async function withDraftStations( planned.some((station) => near(station.chainage_m, section.chainage_m)), ); stale = [...all.filter(isStale), ...retitled]; - const bare = [...all.filter(isStale).filter((section) => section.grid), ...retitled].map( - bareOf, - ); + const bare = [ + // 종류만 바뀐 자리(측점이 남음) — 시설이 떠난 자리는 숨김만 + ...all.filter( + (section) => + isStale(section) && + replaced(section) && + (section.grid || planted.some((at) => near(at, section.chainage_m))), + ), + ...retitled, + ].map(bareOf); live = [...all.filter((section) => !stale.includes(section)), ...bare]; - const missing = planted.filter( - (at, index) => - planted.findIndex((other) => near(other, at)) === index && - live.every((section) => !near(section.chainage_m, at)), - ); - fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : []; - if (fresh.length || bare.length) decorate([...fresh, ...bare], planned); + if (bare.length) decorate(bare, planned); + // 계산 묶음 갈아끼움이 얹은 새 자리 = 카드 이름만(415 M4) + for (const section of live) + if (section.kind === "irregular" && !section.structure) { + const hit = planned.find((station) => near(station.chainage_m, section.chainage_m)); + if (hit) section.structure = hit.title; + } } } if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음 - detail.cross_sections.splice( - 0, - detail.cross_sections.length, - ...[...live, ...fresh].sort(byChainage), - ); + detail.cross_sections.splice(0, detail.cross_sections.length, ...live.sort(byChainage)); parked.set(detail, stale); useStationKeys(projectId, routeId, detail); // 세션 횡단 초안 = 이 측점 목록의 열쇠(344-3) } diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index a78f05d03..9373d725b 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -23,6 +23,7 @@ import { setGeneralStations, setPlusStations } from "../common_util/common_util_ import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations"; import { forgetDraftStations, + supersedeDraftStations, withDraftShapes, withDraftStations, } from "./B06_Section_Section_Drafts"; @@ -170,6 +171,7 @@ export async function swapSectionStations( else useStationKeyList(projectId, routeId, nextKeys); // 메모리만 — 세션 상세 = 저장 정본만(387-7 · 새로고침 · B06 바로 열기 = 묶음이 다시 갈아끼움 · 365-1) cache.set(key, swap.detail); + supersedeDraftStations(current); // 옛 상세에 돌던 맞춤이 새 열쇠 목록을 덮지 않게(415 H1) await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4) withStructureAreas(swap.detail); if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음 diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index 3677b57e7..69529ee76 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -150,16 +150,28 @@ export function stationIntervalOf(detail: KeyedDetail): number { ); } +/** 그대로 세운 목록(계산 묶음 열쇠)이 있는 노선 — 같은 측점이면 주인 규칙으로 다시 세지 않음(415 H1). */ +const given = new Set(); + /** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */ export function useStationKeys(projectId: string, routeId: number, detail: KeyedDetail): void { - const list = stationKeysOf(projectId, detail.cross_sections, stationIntervalOf(detail)); - lists.set(scopeOf(projectId, routeId), list); + const scope = scopeOf(projectId, routeId); + const now = lists.get(scope); + const sections = detail.cross_sections; current = { projectId, routeId }; + // 옮긴 관의 주인 자리가 아직 옛 자리여도 묶음 열쇠(pipe:<저장 id>)를 세션 id 로 덮지 않음 + const same = + now?.length === sections.length && + sections.every((section, i) => Math.abs(now[i].chainageM - section.chainage_m) < NEAR_M); + if (given.has(scope) && same) return; + given.delete(scope); + lists.set(scope, stationKeysOf(projectId, sections, stationIntervalOf(detail))); } /** 열쇠 목록을 그대로 세움 — 계산 묶음 측점(`PipelineStation.key`)이 정한 열쇠(346-2 4파). */ export function useStationKeyList(projectId: string, routeId: number, list: Keyed[]): void { lists.set(scopeOf(projectId, routeId), list); + given.add(scopeOf(projectId, routeId)); current = { projectId, routeId }; } diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts index 8c007f05c..9d1b1a256 100644 --- a/B06_Section/logic/B06_Section_Station_Swap.ts +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -102,7 +102,7 @@ export interface StationSwap { removed: string[]; /** 이어 받은 측점 중 누가거리가 바뀐 것 — 옛 0.01 m 글 → 새 글(조작값 맵 옮김). */ moves: Map; - /** 이어 받은 측점 중 좌표가 0.01 m 넘게 움직인 것이 있나 — 노선이 바뀐 차례(「노선 밖 고침」 셈). */ + /** 이어 받은 관 · 구조물 밖 측점 중 좌표가 0.01 m 넘게 움직인 것이 있나 — 노선이 바뀐 차례(「노선 밖 고침」 셈). */ moved: boolean; } @@ -137,7 +137,13 @@ export function swapCrossSections( const prior = priorOf(station); if (prior) used.add(prior); else added.push(station.key); - if (prior && Math.hypot(prior.center_x - station.x, prior.center_y - station.y) > 0.01) + // 노선이 바뀜 = 관 · 구조물 밖 측점이 움직임 — 시설만 옮기면 고침을 열쇠째 옮김(415 H1) + if ( + prior && + station.kind !== "pipe" && + station.kind !== "structure" && + Math.hypot(prior.center_x - station.x, prior.center_y - station.y) > 0.01 + ) moved = true; if (prior && text(prior.chainage_m) !== text(station.chainageM)) moves.set(text(prior.chainage_m), text(station.chainageM)); diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 958cb22a7..2d0fc2310 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -111,14 +111,18 @@ const chainages = (detail) => detail.cross_sections.map((s) => s.chainage_m); draftPipes = [{ chainage_m: 95, facility: "pipe", source: "user" }]; // 95.38 → 95 await drafts.withDraftStations(detail, "P", 1); await drafts.withDraftShapes(detail, "P"); // 그림 자료(323-4 · `Section_Store` 차례) - const preview = detail.cross_sections.find((s) => s.chainage_m === 95); - out.moved = { + out.moved = { chainages: chainages(detail), asked: asked.splice(0) }; + // 새 자리 = 계산 묶음 갈아끼움이 얹은 측점(415 M4) — 다음 맞춤이 그대로 둠 · 관 그림 + detail.cross_sections.push({ chainage_m: 95, kind: "irregular", station_id: "s95", samples: [] }); + detail.cross_sections.sort((a, b) => a.chainage_m - b.chainage_m); + await drafts.withDraftStations(detail, "P", 1); + await drafts.withDraftShapes(detail, "P"); + const swapped = detail.cross_sections.find((s) => s.chainage_m === 95); + out.again = { chainages: chainages(detail), asked: asked.splice(0), - preview: { preview: preview.preview, structure: preview.structure, culvert: preview.culvert }, + swapped: { preview: !!swapped?.preview, culvert: !!swapped?.culvert }, }; - await drafts.withDraftStations(detail, "P", 1); - out.again = { chainages: chainages(detail), asked: asked.splice(0) }; draftPipes = null; await drafts.withDraftStations(detail, "P", 1); out.cleared = { chainages: chainages(detail) }; diff --git a/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs index 7ab54a238..8ecb22a34 100644 --- a/resources/tester/helper_318_cross_card.cjs +++ b/resources/tester/helper_318_cross_card.cjs @@ -472,6 +472,8 @@ const out = {}; // ── 5. 미리 뜬 횡단 사본 걷음(318-4) — 그 자리 조정값만 · 다른 칸 그대로 ───────── const state = { draftPipes: null, savedPipes: [] }; +/** 서버 미리보기 요청(415 M4 — 0 이어야 함). */ +const asked = []; const loadDrafts = makeLoader( [ B06("B06_Section_Section_Drafts"), @@ -499,7 +501,7 @@ const loadDrafts = makeLoader( "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes }, "./B06_Section_Api_Fetch": { previewStructureSections: async (_p, chainages) => - chainages.map((at) => ({ + (asked.push(chainages), chainages).map((at) => ({ chainage_m: at, kind: "irregular", design: { ground_type: "soil", ford_adjust: { w: 1 }, revet_adjust: { inlet: { x: 1 } } }, @@ -524,15 +526,16 @@ const loadStore = makeLoader( ); (async () => { + // 415 M4 — 새 자리 서버 미리보기 걷음 · 조정값 사본도 없음(새 자리는 계산 묶음 갈아끼움이 얹음) const { withDraftStations, forgetPreviewAdjusts } = loadDrafts(B06("B06_Section_Section_Drafts")); const detail = { cross_sections: [{ chainage_m: 0, kind: "regular", design: {} }] }; state.draftPipes = [{ chainage_m: 50, facility: "box_culvert" }]; await withDraftStations(detail, "P", 7); - const at50 = () => detail.cross_sections.find((s) => Math.abs(s.chainage_m - 50) < 0.01); - const had = Object.keys(at50()?.design ?? {}).sort(); - forgetPreviewAdjusts("P", 50, ["ford_adjust", "revet_adjust"]); - forgetPreviewAdjusts("OTHER", 0, ["ground_type"]); - out.preview = { had, after: Object.keys(at50()?.design ?? {}).sort() }; + out.preview = { + asked: asked.splice(0), + chainages: detail.cross_sections.map((s) => s.chainage_m), + forget: typeof forgetPreviewAdjusts, + }; const { crossPatchesFromCache } = loadStore(B06("B06_Section_Section_Store")); out.cache_patches = crossPatchesFromCache( diff --git a/resources/tester/helper_322_0_golden.cjs b/resources/tester/helper_322_0_golden.cjs index ec272f756..b0c1d47c4 100644 --- a/resources/tester/helper_322_0_golden.cjs +++ b/resources/tester/helper_322_0_golden.cjs @@ -235,6 +235,26 @@ const { pickGroups } = loadTs(B05("B05_Profile_UI_Structures_Panel_Types")); const { structureDisplayName } = loadTs(B06("B06_Section_UI_Section_Common")); const { listPick } = loadTs(file("common_util", "common_util_structure_catalog.ts")); +/** 415 M4 — 새 자리 = 계산 묶음 측점 갈아끼움 흉내(측점 계획 자리 중 상세에 없는 것 · 서버 안 물음). */ +function swapIn(detail, types) { + const plan = loadTs(file("common_util", "common_util_structure_station.ts")); + const { registryDefaults } = loadTs(file("common_util", "common_util_culvert_sets.ts")); + const structures = JSON.parse( + window.sessionStorage.getItem(`aislo:draft:structures:${PROJECT}`) ?? "null", + ); + const planned = plan.planStations( + pendingPipes ?? [], + structures ?? [], + types, + registryDefaults(types), + ); + const near = (a, b) => Math.abs(a - b) < plan.SAME_STATION_M; + for (const { chainage_m: at } of planned) + if (detail.cross_sections.every((s) => !near(s.chainage_m, at))) + detail.cross_sections.push({ ...plainAt(at), kind: "irregular" }); + detail.cross_sections.sort((a, b) => a.chainage_m - b.chainage_m); +} + async function main() { const types = await fetchStructureTypes(true); const byId = new Map(types.map((type) => [type.type_id, type])); @@ -267,7 +287,8 @@ async function main() { forgetDraftStations(PROJECT); pendingPipes = sample.pipe ? [clone(sample.pipe)] : null; writePendingStructures(PROJECT, sample.structure ? [clone(sample.structure)] : null); - const detail = await loadSectionDetail(PROJECT, route, { force: true }); + swapIn(await loadSectionDetail(PROJECT, route, { force: true }), types); + const detail = await loadSectionDetail(PROJECT, route); // 갈아끼움 뒤 다시 그림 = 캐시 다시 맞춤 const touched = detail.cross_sections.filter( (section) => section.structure || Object.keys(shapeOf(section)).length, ); diff --git a/resources/tester/helper_322_3_flow.cjs b/resources/tester/helper_322_3_flow.cjs index 0dddc8d54..1c4604c72 100644 --- a/resources/tester/helper_322_3_flow.cjs +++ b/resources/tester/helper_322_3_flow.cjs @@ -128,10 +128,29 @@ async function run(setup, detail) { drafts.forgetDraftStations("P"); route += 1; await drafts.withDraftStations(detail, "P", route); + swapIn(detail); + await drafts.withDraftStations(detail, "P", route); // 갈아끼움 뒤 다시 그림 = 다시 맞춤 await drafts.withDraftShapes(detail, "P"); // 그림 자료 = 형상 얹기 한 벌(323-4) return summary(detail); } +/** 415 M4 — 새 자리 = 계산 묶음 측점 갈아끼움 흉내(측점 계획 자리 중 상세에 없는 것 · 기본 설계 · 서버 안 물음). */ +function swapIn(detail) { + const plan = load(file("common_util", "common_util_structure_station.ts")); + const { registryDefaults } = load(file("common_util", "common_util_culvert_sets.ts")); + const planned = plan.planStations( + state.draftPipes ?? state.savedPipes, + state.draftStructures ?? state.savedStructures, + TYPES, + registryDefaults(TYPES), + ); + const near = (a, b) => Math.abs(a - b) < plan.SAME_STATION_M; + for (const { chainage_m: at } of planned) + if (detail.cross_sections.every((s) => !near(s.chainage_m, at))) + detail.cross_sections.push({ chainage_m: at, kind: "irregular", station_id: `p${at}` }); + detail.cross_sections.sort((a, b) => a.chainage_m - b.chainage_m); +} + (async () => { const out = {}; out.place = await run({ draftStructures: [wall("retaining_wall")] }, { cross_sections: grid() }); diff --git a/resources/tester/helper_415_h2.cjs b/resources/tester/helper_415_h2.cjs new file mode 100644 index 000000000..abb5e8364 --- /dev/null +++ b/resources/tester/helper_415_h2.cjs @@ -0,0 +1,278 @@ +/* 415 H2 헬퍼 — [저장] 도중 계산 묶음 먹이기 멈춤 · 끝나면 저장 목록 다시 읽음. 진짜 TS(`design_pipeline_page` · + * 단계 파일 전부)를 트랜스파일해 Node 로 돌림 · 서버 창구 · 저장 중 표시만 가짜(틀 = `helper_365_page_pipeline.cjs`). + * 입력 argv[2] JSON · argv[3] 격자 이진 · 결과 = 표준출력 JSON. 짝 = `test_415_page_split.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"); + +const ROOT = path.join(__dirname, "..", ".."); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +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; + }, + }; +} +globalThis.window = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, +}; + +// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 +const haul = { calls: 0, aborted: 0, answered: 0 }; +globalThis.fetch = (url, init) => + new Promise((resolve, reject) => { + haul.calls += 1; + const timer = setTimeout(() => { + haul.answered += 1; + resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }); + }, job.haulDelayMs); + init.signal.addEventListener("abort", () => { + clearTimeout(timer); + haul.aborted += 1; + reject(new Error("aborted")); + }); + }); + +const STUBS = {}; +const cache = new Map(); +function loadTs(full) { + 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 (CATALOG.isCatalogRequest(request)) return CATALOG; + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.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); + return box.exports; +} +const load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +const sampler = load("common_util", "common_util_surface_sampler.ts"); +const bytes = fs.readFileSync(process.argv[3]); +const grid = sampler.parseDtmGrid( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), +); +const local = { grid, design: "las" }; + +STUBS["B05_Profile_Api_Replan"] = { + analysisTerrain: () => "las", + fetchRoutePlan: async () => ({ + planned: job.line, + curves: [], + min_radius_m: job.minRadius, + station_snap: false, + }), +}; +STUBS["B05_Profile_Api_Structures"] = { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => job.types, +}; +STUBS["B05_Profile_Api_TerrainGrid"] = { + loadDesignTerrainGrid: async () => local, + groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), +}; +/** 서버 관 저장분 — [저장] 뒤 바뀜 · 읽은 횟수(저장 뒤 다시 읽음). */ +let serverPipes = job.pipes; +let pipeReads = 0; +STUBS["B04_PreProcess_Api_Fetch"] = { + fetchDetailPipePoints: async () => { + pipeReads += 1; + return { pipe_points: serverPipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })) }; + }, +}; +/** [저장] 도중 — 묶음이 늦게 불러오는 저장 창구 가짜(415 H2). */ +let saving = false; +STUBS["design_save"] = { savingDesign: () => saving }; +STUBS["B06_Section_UI_Standard_Panel"] = { + effectiveStandardCross: () => job.standard, + readRockBoundaryDefault: () => -0.5, +}; + +const pipeMod = load("A00_Common", "design", "logic", "design_pipeline.ts"); +const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); +const stationsMod = load("A00_Common", "design", "logic", "design_pipeline_stations.ts"); +const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts"); +const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts"); +const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts"); +const detailMod = load("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts"); +const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); +const editMod = load("B05_Profile", "B05_Profile_UI_Profile_Edit.ts"); +const page = load("A00_Common", "b_page_state.ts"); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const runs = () => globalThis.__aisloPipeline?.runs ?? []; +async function settle(count) { + for (let i = 0; i < 400; i += 1) { + const last = runs()[runs().length - 1]; + if (runs().length >= count && last && last.outcome !== "running") return last; + await tick(5); + } + throw new Error("묶음이 끝나지 않음"); +} +const n = job.nodes.length; +const draftOf = (nodes) => ({ + nodes, + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + stationSnap: false, +}); +const shift = (index, dx, dy) => + job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); + +/** 저장 기준 — 저장 노선 지반 첫 계획선(관 측점 포함 · 서버 정책 그대로). */ +async function savedBaseOf() { + const seed = pipeMod.createDesignPipeline({ + stages: { + route: routeGround.createRouteStage({ + minRadiusM: () => job.minRadius, + savedLine: () => ({ line: job.line, curves: [] }), + }), + stations: stationsMod.createStationsStage({ + intervalM: job.interval, + types: () => job.types, + }), + ground: routeGround.createGroundStage({ grid: () => local, remote: async () => [] }), + }, + }); + await seed.start({ route: null, alignment: null, cross: null, pipes: job.pipes, structures: [] }); + const ground = seed.output("ground"); + seed.dispose(); + const samples = ground.longitudinal.chainageM.map((c, i) => ({ + chainage_m: c, + elevation_m: ground.longitudinal.z[i], + })); + const profile = gradeGround.groundProfile(samples); + return { + policy: job.policy, + onlyExplicitCurves: false, + basePvi: gradeGround.groundFollowingBasePvi({ + samples, + stations: ground.stations.map((s) => s.chainageM), + policy: job.policy, + }), + chainage: profile.chainage, + ground: profile.ground, + stations: ground.stations.map((s) => ({ + station_id: grade.stationIdOf(s.chainageM), + chainage_m: s.chainageM, + })), + }; +} + +const store = load("A00_Common", "design", "design_store.ts"); + +const pipeAt = () => + pageMod + .pagePipeline(P) + .input("pipes") + .map((pipe) => pipe.chainage_m); +const stationAt = () => + (shop.get("stations") ?? []).filter((s) => s.kind === "pipe").map((s) => s.chainageM); +const P = "proj-415"; +const shop = store.useDesignStore(P, null); + +async function run() { + window.localStorage.setItem("frd_current_project_id", P); + const savedBase = await savedBaseOf(); + const link = { + pipelineSource: () => ({ + base: savedBase, + edits: alignment.emptyEdits(), + detail: job.detail, + routeId: 7, + }), + adoptPipeline: () => undefined, + redraw: () => undefined, + }; + pageMod.routeDraftBridge(P, link, { stationIntervalM: job.interval }); + pageMod.pipelineEarthwork(P, { conversion: job.conversion }); + await settle(runs().length + 1); + const out = { open: pipeAt() }; + + // ① 관 5 m 옮김(저장 전 초안) — 묶음이 새 자리로 + const moved = job.pipes.map((pipe) => ({ ...pipe, chainage_m: pipe.chainage_m + 5 })); + shop.set("pipes", moved); + await settle(runs().length + 1); + out.moved = { input: pipeAt(), stations: stationAt() }; + + // ② [저장] 도중 — 관 차례가 서버에 쓰고 초안을 비움 · 맞춤 고리도 불림 + saving = true; + serverPipes = moved; + const mark = runs().length; + const reads = pipeReads; + shop.set("pipes", null); + const rounds = await pageMod.reconcilePagePipeline(P); + await tick(150); + out.during = { + runs: runs().length - mark, + rounds, + input: pipeAt(), + stations: stationAt(), + reads: pipeReads - reads, + }; + + // ③ 저장 끝 — 저장 목록 다시 읽고 맞춤(같은 자리 = 다시 안 셈) + saving = false; + await tick(400); + await pageMod.pagePipeline(P).settled(); + out.after = { + runs: runs().length - mark, + input: pipeAt(), + stations: stationAt(), + reads: pipeReads - reads, + sources: runs() + .slice(mark) + .map((r) => r.sources), + }; + // ④ 저장 뒤 맞춤 고리 — 저장분(새 자리) 대 묶음 입력 같음 = 0 바퀴 + out.reconcile = await pageMod.reconcilePagePipeline(P); + + // ⑤ 관 옮김 → 되돌리기(415-2) — 세션만 되쓰는 사진첩 · 묶음 입력 · 측점이 원래로 · 다시하기 = 옮긴 자리 + const history = load("B05_Profile", "B05_Profile_UI_Profile_History.ts"); + const book = history.createProfileHistory(() => undefined); + const again = moved.map((pipe) => ({ ...pipe, chainage_m: pipe.chainage_m + 5 })); + shop.set("pipes", again); + await settle(runs().length + 1); + book.record(); + const movedAgain = { input: pipeAt(), stations: stationAt() }; + book.undo(); + await tick(50); + await pageMod.pagePipeline(P).settled(); + const undone = { input: pipeAt(), stations: stationAt(), draft: shop.get("pipes") }; + book.redo(); + await tick(50); + await pageMod.pagePipeline(P).settled(); + out.undo = { movedAgain, undone, redone: { input: pipeAt(), stations: stationAt() } }; + pageMod.disposePagePipeline(P); + return out; +} + +run() + .then((out) => process.stdout.write(JSON.stringify(out))) + .catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); + }); diff --git a/resources/tester/helper_415_swap.mjs b/resources/tester/helper_415_swap.mjs new file mode 100644 index 000000000..238141fbe --- /dev/null +++ b/resources/tester/helper_415_swap.mjs @@ -0,0 +1,149 @@ +/* 415 H1 헬퍼 — 관만 옮김 = 횡단 고침을 측점 열쇠째 옮김 · 노선이 바뀜 = 「노선 바뀜」 넘김 규칙. + * node helper_415_swap.mjs <묶음.mjs> + * 묶음 = 346-2 진입점(`helper_346_2_swap_entry.ts` · vite ssr 진짜 모듈) · 표준출력 JSON 한 벌. 짝 = test_415_page_split.py. + */ +import { pathToFileURL } from "node:url"; + +const store = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); +globalThis.__toasts = []; + +const m = await import(pathToFileURL(process.argv[2]).href); +const P = "p415"; +const R = 3; +const out = {}; + +/* 옛 목록 — 시점 · 격자 넷 · 관 하나(45.054) · 종점(간격 20 m) */ +const section = (chainage_m, kind, extra = {}) => ({ + station_id: `station_${String(Math.round(chainage_m * 1000)).padStart(12, "0")}`, + chainage_m, + label: "", + kind, + center_z: 100, + azimuth_deg: 90, + center_x: chainage_m, + center_y: 0, + frame: { left_xy: [0, 1] }, + samples: [{ offset_m: 0, elevation_m: 100, valid: true }], + design: { design_elevation_m: 101 }, + ...extra, +}); +const detail = () => ({ + longitudinal: { + length_m: 100, + stations: [], + profile_alignment: { policy: { station_interval_m: 20 } }, + }, + cross_sections: [ + section(0, "bp"), + section(20, "regular"), + section(40, "regular"), + section(45.054, "irregular", { structure: "파형강관 D800", culvert: {} }), + section(60, "regular"), + section(80, "regular"), + section(100, "ep"), + ], +}); +const station = (key, chainageM, kind, id = null, dy = 0) => ({ + key, + chainageM, + x: chainageM, + y: dy, + azimuthDeg: 90, + left: [0, 1], + kind, + id, + ground: { centerZ: 99, left: [{ offsetM: 0.5, z: 99.1 }], right: [{ offsetM: 0.5, z: 98.9 }] }, + planRadiusM: null, + curveOuterSide: null, + curveWideningM: null, +}); +/** 관 자리 `pipeAt` · 노선 옆 밀림 `dy`(0 = 노선 그대로) · 종점 `endAt`. */ +const list = (pipeAt, dy = 0, endAt = 100) => + [ + station("end:BP", 0, "end", null, dy), + station("grid:1", 20, "grid", null, dy), + station("grid:2", 40, "grid", null, dy), + station("pipe:pipe-s1", pipeAt, "pipe", "pipe-s1", dy), + station("grid:3", 60, "grid", null, dy), + station("grid:4", 80, "grid", null, dy), + station("end:EP", endAt, "end", null, dy), + ].sort((a, b) => a.chainageM - b.chainageM); + +const choice = (mode) => ({ ground_type: "soil", section_mode: mode, ditch_side: "right" }); +const snap = () => ({ + choices: [...m.crossDesignChoices(P, R).entries()] + .sort((a, b) => a[0] - b[0]) + .map(([at, c]) => [at, c.ground_type ?? null, c.section_mode ?? null]), + shoulder: Object.entries(m.readState("shoulderext", P, R) ?? {}) + .filter(([k]) => k !== "@at") + .map(([k, v]) => [k, v]), + berm: Object.entries(m.readState("bermdepth", P, R) ?? {}) + .filter(([k]) => k !== "@at") + .map(([k, v]) => [k, v]), +}); + +/* 1. 순수 — 관만 옮김 = moved 아님 · 노선이 옆으로 밀림 = moved */ +{ + const keys = ["end:BP", "grid:1", "grid:2", "pipe:pipe-s1", "grid:3", "grid:4", "end:EP"]; + out.pure = { + pipeOnly: m.swapCrossSections(detail(), list(50), keys, 20).moved, + route: m.swapCrossSections(detail(), list(50, 0.5, 95), keys, 20).moved, + }; +} + +/* 2. 가게 측점 → 상세 갈아끼움 · 세션 고침 */ +{ + m.writeState("section-detail", detail(), P, R); + m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 45.054 }] }, P); + await m.loadSectionDetail(P, R); // 세션 한 벌 → 캐시 · 측점 열쇠 세움 + m.writeCrossDesignChoice(P, R, 40, choice("right_cut")); // 단면 유형 + m.writeCrossDesignChoice(P, R, 45.054, choice("left_cut")); // 관 측점 고침 + m.writeState("shoulderext", { "@at": { "grid:1": 20 }, "grid:1": { left: 0.7 } }, P, R); // 사면 쉬프트 + m.writeState("bermdepth", { "@at": { "grid:3": 60 }, "grid:3": { right: 0.4 } }, P, R); // 소단 깊이 + out.before = snap(); + const stop = m.followSwaps( + P, + () => R, + { visible: () => true }, + async () => undefined, + ); + // 관 5 m 옮김(노선 그대로) — 관 자리 장부도 새 자리 + m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 50 }] }, P); + m.useDesignStore(P, null).set("stations", list(50)); + await new Promise((resolve) => setTimeout(resolve, 30)); + out.pipeMoved = { ...snap(), toasts: globalThis.__toasts.length }; + // 관 한 번 더(10 m) — 둘째 옮김도 그대로(첫 옮김 뒤 관 id 를 앎) + m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 55 }] }, P); + m.useDesignStore(P, null).set("stations", list(55)); + await new Promise((resolve) => setTimeout(resolve, 30)); + out.pipeMovedAgain = snap(); + // 노선이 바뀜(옆으로 0.5 m · 종점 95 m) = 넘김 규칙 — 단면 유형 · 사면 쉬프트 · 소단 깊이 버림 · 지반 유형 넘김 + m.useDesignStore(P, null).set("stations", list(55, 0.5, 95)); + await new Promise((resolve) => setTimeout(resolve, 30)); + out.routeMoved = snap(); + stop(); +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_273_2_preview_stations.py b/resources/tester/test_273_2_preview_stations.py index 69236dacb..672b7fd3e 100644 --- a/resources/tester/test_273_2_preview_stations.py +++ b/resources/tester/test_273_2_preview_stations.py @@ -3,8 +3,8 @@ - 미리 뜨기(`sample_structure_sections`)는 파일을 안 쓰고, [저장] 때 정본 쓰기 (`generate_irregular_sections`)와 같은 횡단을 낸다(같은 길 · 화면이 본 값 = 저장 값). - 기본 설계(`compute_default_designs`)는 파일 대신 받은 횡단으로도 셈. -- 화면 쪽: 옛 자리 숨김 · 새 자리 얹음 · 이름 · 관 그림 · 늦게 끝난 옛 맞춤이 새 것을 안 덮음 - (`helper_273_2_preview_stations.cjs`). +- 화면 쪽: 옛 자리 숨김 · 새 자리는 안 물음(415 M4 · 계산 묶음 갈아끼움이 얹음) · 관 그림 · 늦게 끝난 옛 + 맞춤이 새 것을 안 덮음(`helper_273_2_preview_stations.cjs`). """ from __future__ import annotations @@ -111,22 +111,21 @@ def ts() -> dict: return json.loads(proc.stdout) -def test_화면_옛_자리_숨기고_새_자리_얹음(ts) -> None: - assert ts["moved"]["chainages"] == [0, 20, 40, 95, 100] # 옛 95.38 숨김 · 새 95 미리 뜬 것 - assert ts["moved"]["asked"] == [[95]] - card = ts["moved"]["preview"] - assert card["preview"] is True and card["structure"] == "파형강관 D800" - assert card["culvert"] is True # 관 그림(세트)도 얹힘 +def test_화면_옛_자리_숨기고_새_자리_안_물음(ts) -> None: + # 415 M4 — 새 자리 서버 미리보기 걷음(브라우저 계산 줄 하나) · 옛 95.38 숨김 · 미리보기 요청 0 + assert ts["moved"]["chainages"] == [0, 20, 40, 100] + assert ts["moved"]["asked"] == [] -def test_한_번_뜬_자리는_다시_안_물음(ts) -> None: +def test_묶음이_얹은_새_자리는_그대로_관_그림(ts) -> None: assert ts["again"]["asked"] == [] assert ts["again"]["chainages"] == [0, 20, 40, 95, 100] + assert ts["again"]["swapped"] == {"preview": False, "culvert": True} def test_초안이_없으면_서버_목록_그대로(ts) -> None: - assert ts["cleared"]["chainages"] == [0, 20, 40, 95.38, 100] + assert ts["cleared"]["chainages"] == [0, 20, 40, 95, 95.38, 100] def test_늦게_끝난_옛_맞춤이_새_것을_안_덮음(ts) -> None: - assert ts["race"]["chainages"] == [0, 20, 40, 100, 120] + assert ts["race"]["chainages"] == [0, 20, 40, 100] diff --git a/resources/tester/test_298_add_reanalyze.py b/resources/tester/test_298_add_reanalyze.py index a11e4f16c..c9887465f 100644 --- a/resources/tester/test_298_add_reanalyze.py +++ b/resources/tester/test_298_add_reanalyze.py @@ -159,7 +159,7 @@ def test_추가_뒤_새_측점_횡단_미리_뜸(): drafts, "export async function withDraftStations(", "parked.set(detail, stale);" ) assert "readPendingPipes(projectId)" in stations - assert "previewOf(projectId, routeId, missing)" in stations + assert "previewStructureSections" not in drafts # 415 M4 — 새 자리는 계산 묶음 갈아끼움이 얹음 def test_B06_알림_글_뒤에서_다시_나눔(): diff --git a/resources/tester/test_317_kind_change_station.py b/resources/tester/test_317_kind_change_station.py index 6323ead84..41a110050 100644 --- a/resources/tester/test_317_kind_change_station.py +++ b/resources/tester/test_317_kind_change_station.py @@ -1,7 +1,7 @@ """PLAN 317 — 구조물 측점에서 종류를 바꾸면 횡단을 「구조물 없는 상태」부터 새로(요구 213). - 저장분과 같은 종류 = 저장분 횡단 그대로 -- 다른 종류로 바꿈 = 저장분 횡단은 빼 두고 새 자리처럼 미리 뜬 횡단(옛 구조물 흔적 없음) +- 다른 종류로 바꿈 = 저장분 횡단은 빼 두고 구조물 없는 사본(옛 구조물 흔적 없음 · 415 M4 = 서버 미리보기 안 물음) - 저장분 종류로 되돌림 = 빼 둔 저장분 횡단이 돌아옴 실제 TS(`withDraftStations`)를 `helper_317_kind_change_station.cjs` 가 Node 로 돌린다(서버 부르는 곳 흉내). @@ -42,7 +42,8 @@ def test_same_kind_keeps_saved_section(result: dict) -> None: def test_changed_kind_starts_from_fresh_section(result: dict) -> None: - assert result["changed_kind"] == {"count": 2, "fresh": True, "old": False, "preview": True} + # 415 M4 — 서버 미리보기(fresh) 대신 구조물 없는 사본 · 설계는 브라우저 다시 셈이 맡음 + assert result["changed_kind"] == {"count": 2, "fresh": False, "old": True, "preview": True} def test_back_to_saved_kind_restores_saved_section(result: dict) -> None: diff --git a/resources/tester/test_318_cross_card.py b/resources/tester/test_318_cross_card.py index 0ef30c293..4fa36eefa 100644 --- a/resources/tester/test_318_cross_card.py +++ b/resources/tester/test_318_cross_card.py @@ -183,7 +183,7 @@ def test_forget_session_adjusts_at_station(result: dict) -> None: assert forget["basinadjust"] == {} and forget["inletstruct"] == {} assert forget["crossw"] == {"100.00": 5} # 구조물 아닌 값(표시 반폭)은 그대로 assert forget["reloads"] == 1 # 횡단 보기 메모리 사본 다시 읽음 - assert forget["preview_calls"][0] == ["P", 100, list(STRUCTURE_ADJUST_KEYS)] + assert forget["preview_calls"] == [] # 415 M4 — 미리 뜬 횡단 사본 없음 def test_kind_reset_marks(result: dict) -> None: @@ -196,8 +196,8 @@ def test_kind_reset_marks(result: dict) -> None: def test_preview_copy_stripped(result: dict) -> None: - assert result["preview"]["had"] == ["ford_adjust", "ground_type", "revet_adjust"] - assert result["preview"]["after"] == ["ground_type"] + # 415 M4 — 새 자리 서버 미리보기 요청 0 · 사본 없음 + assert result["preview"] == {"asked": [], "chainages": [0], "forget": "undefined"} def test_b05_save_path_patches(result: dict) -> None: diff --git a/resources/tester/test_322_3_flow.py b/resources/tester/test_322_3_flow.py index 7c85d1c5d..9236eee7a 100644 --- a/resources/tester/test_322_3_flow.py +++ b/resources/tester/test_322_3_flow.py @@ -39,10 +39,11 @@ def _marked(rows: list) -> list: def test_place(flows: dict) -> None: + # 415 M4 — 새 자리 = 계산 묶음 갈아끼움(미리 뜬 카드 아님 · 서버 안 물음) · 이름 그대로 assert _marked(flows["place"]) == [ - [93, "옹벽", True, []], - [101, "옹벽", True, []], - [113, "옹벽", True, []], + [93, "옹벽", False, []], + [101, "옹벽", False, []], + [113, "옹벽", False, []], ] @@ -56,7 +57,7 @@ def test_place_on_general_station_gets_name_and_shape(flows: dict) -> None: def test_move_hides_old_and_adds_new(flows: dict) -> None: - assert _marked(flows["move"]) == [[75, "파형강관 D1000", True, ["culvert"]]] + assert _marked(flows["move"]) == [[75, "파형강관 D1000", False, ["culvert"]]] # 415 M4 assert all(row[0] != 61 for row in flows["move"]) @@ -108,6 +109,7 @@ def test_saved_decimal_stations_follow_plan(flows: dict) -> None: # 소수 자리 저장분 = B05 를 먼저 연 것과 같음 — 소수 카드는 빼 두고 정수 자리(격자 자리는 사본) rows = flows["snapSavedDec"] assert [row[0] for row in _marked(rows)] == SNAPPED - assert all(row[2] for row in _marked(rows)) + # 격자 자리 = 구조물 없는 사본(미리 뜬 카드) · 나머지 = 계산 묶음 갈아끼움(415 M4) + assert all(row[2] == (row[0] % 20 == 0) for row in _marked(rows)) assert all(float(row[0]).is_integer() for row in rows) assert len(rows) == 58 diff --git a/resources/tester/test_415_page_split.py b/resources/tester/test_415_page_split.py new file mode 100644 index 000000000..f0504c1da --- /dev/null +++ b/resources/tester/test_415_page_split.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +"""PLAN 415 — 한 페이지 끊김 ① (분석 412 3장 H1 · H2 · M4). + +못박는 것 + - H1 관 · 구조물만 옮김 = 「노선 바뀜」 넘김 규칙 안 탐 — 측점 열쇠째 옮김(단면 유형 · 사면 쉬프트 · + 소단 깊이 · 관 측점 고침 그대로 · 두 번 옮겨도) · 노선 입력이 바뀐 때만 넘김 규칙(대조). + - H2 [저장] 도중 계산 줄 먹이기 멈춤 — 관 초안이 비어도 열 때 읽은 옛 관 목록으로 안 돎 · 맞춤 고리도 쉼 · + 저장 끝 = 저장 목록 다시 읽음 · 새 자리 그대로(다시 셈 0). + - 415-2 관 옮김 → 되돌리기 = 묶음 입력 · 공용 측점도 원래 · 다시하기 = 옮긴 자리. + - M4 새 시설 측점 서버 미리보기 걷음 — 미리보기 요청 0(`test_273_2` · `test_318` 이 맡음) · 여기는 코드 자리. + +짝: `helper_415_swap.mjs`(346-2 진입점 묶음 · 진짜 상세 가게) · `helper_415_h2.cjs`(진짜 계산 묶음 · 365 틀). +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import test_365_page_pipeline as t365 # noqa: E402 + +pytestmark = t365.pytestmark + + +@pytest.fixture(scope="module") +def h1(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("b415") + build = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_346_2_swap_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_346_2_swap_entry.js", bundle) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_415_swap.mjs"), str(bundle)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def h2() -> dict: + with tempfile.TemporaryDirectory() as folder: + path, grid = t365._job(Path(folder)) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_415_h2.cjs"), str(path), str(grid)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=180, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_H1_순수_관만_옮김은_노선_바뀜_아님(h1): + assert h1["pure"] == {"pipeOnly": False, "route": True} + + +def test_H1_관_옮김_뒤_고침_그대로(h1): + before = h1["before"] + assert before["choices"] == [[40, "soil", "right_cut"], [45.05, "soil", "left_cut"]] + assert before["shoulder"] == [["grid:1", {"left": 0.7}]] + assert before["berm"] == [["grid:3", {"right": 0.4}]] + moved = h1["pipeMoved"] + # 관 측점 고침은 새 자리(50 m)로 · 격자 고침 그대로 · 「노선 밖 고침」 알림 없음 + assert moved["choices"] == [[40, "soil", "right_cut"], [50, "soil", "left_cut"]] + assert moved["shoulder"] == before["shoulder"] and moved["berm"] == before["berm"] + assert moved["toasts"] == 0 + again = h1["pipeMovedAgain"] + assert again["choices"] == [[40, "soil", "right_cut"], [55, "soil", "left_cut"]] + assert again["shoulder"] == before["shoulder"] and again["berm"] == before["berm"] + + +def test_H1_노선이_바뀌면_넘김_규칙(h1): + routed = h1["routeMoved"] + # 대조 — 노선 입력이 바뀐 때 = 지반 유형만 넘기고 단면 유형 · 사면 쉬프트 · 소단 깊이 버림 + assert routed["choices"] == [[40, "soil", None], [55, "soil", None]] + assert routed["shoulder"] == [] and routed["berm"] == [] + + +def test_H2_저장_도중_먹이기_멈춤(h2): + opened = h2["open"] + assert h2["moved"]["input"] == [c + 5 for c in opened] + during = h2["during"] + assert during["runs"] == 0 and during["rounds"] == 0 # 옛 관 목록으로 안 돎 · 고리도 쉼 + assert during["input"] == h2["moved"]["input"] + assert during["stations"] == h2["moved"]["stations"] + assert during["reads"] == 0 + + +def test_H2_저장_끝_저장_목록_다시_읽음(h2): + after = h2["after"] + assert after["reads"] >= 1 # 저장 목록 다시 읽음 + assert after["input"] == h2["moved"]["input"] # 새 자리 그대로 + assert after["stations"] == h2["moved"]["stations"] + # 관 · 구조물 다시 먹이기 없음(옛 목록으로 안 돎) — 저장 측점 기준이 새 자리라 횡단만 한 번 + assert all(source == ["cross"] for source in after["sources"]) and after["runs"] <= 1 + assert h2["reconcile"] == 0 + + +def test_M4_새_시설_측점_미리보기_걷음(): + drafts = (ROOT / "B06_Section/B06_Section_Section_Drafts.ts").read_text(encoding="utf-8") + assert "previewStructureSections" not in drafts + page = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8") + guard = (ROOT / "A00_Common/design/design_pipeline_facilities.ts").read_text(encoding="utf-8") + # 저장 창구는 늦게 불러옴 — 묶음(시험 · 화면)이 저장 모듈을 맨 위에서 안 끎 + assert 'from "./design_save"' not in page and 'import("./design_save")' in guard + + +def test_415_2_관_옮김_되돌리기_측점_원래(h2): + undo = h2["undo"] + base = h2["moved"]["input"] # 저장 뒤 저장분 = 옮긴 자리 + assert undo["movedAgain"]["input"] == [c + 5 for c in base] + # 되돌리기 = 세션만 되씀(가게 알림 없음) → 묶음 입력 · 공용 측점도 원래 + assert undo["undone"]["draft"] is None + assert undo["undone"]["input"] == base and undo["undone"]["stations"] == base + assert undo["redone"]["input"] == undo["movedAgain"]["input"] + assert undo["redone"]["stations"] == undo["movedAgain"]["stations"]