From 20f2044afe3b65fe83d67db9902053d5ee3988f8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 12:14:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8=EB=AA=A9=EB=A1=9D):=203?= =?UTF-8?q?46-2=20=EB=8D=A7=20=EC=B8=A1=EC=A0=90=20=EC=88=98=EA=B0=80=20?= =?UTF-8?q?=EB=B0=94=EB=80=94=20=EB=95=8C=20=ED=9A=A1=EB=8B=A8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=88=EC=95=84=EB=81=BC=EC=9B=80=20=E2=80=94=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C=20=EC=B8=A1=EC=A0=90=20=E2=86=92=20=EC=83=88?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=C2=B7=20=EC=97=B4=EC=87=A0=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EB=B0=9B=EA=B8=B0=20=C2=B7=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8A=94=20=EC=B9=B4=EB=93=9C=20=EB=A8=BC=EC=A0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logic/B06_Section_Station_Swap: 묶음 측점(PipelineStation[]) → 새 상세 객체 · 같은 측점(열쇠 · 없으면 같은 자리 같은 종류)은 앞 횡단 이어 받음(설계 · 구조물 제원 · 이름) · 자리 · 지반 표본만 새것 · 새 측점 = 지반 표본 + 기본 설계 · 빠진 측점 뺌 · formatStation(파이썬 format_station 짝) - Section_Store swapSectionStations: 같은 목록이면 그대로 · 바뀌면 캐시 · 세션 상세 갈아끼움 · 옛 열쇠 = 세워 둔 목록(옮긴 관도 옛 id) · 새 열쇠 = 묶음이 정한 것 · 노선 모양이 바뀐 차례면 세션 고침 넘김(사라진 측점 = 「노선 밖 고침 N 건」) · 듣는 쪽 subscribeSectionDetail - 횡단 보기 followSwaps(가게 stations 따라감): 조정창 값(반폭 · 기슭막이 · 유입 · 집수정 · 추가 벽)을 옮긴 자리로 · 암 경계 · 암 경사 다시 읽음 · 보일 때 카드 · 종단도 다시 · 떠난 보기의 옛 맵은 지금 값을 안 덮음 - 카드 통째 그리기 = 보이는 줄 + 한 줄만 바로 · 나머지는 같은 id 빈 자리 → 쉬는 틈에 진짜 카드 - 시험: 셋 늘고 둘 빠진 목록(카드 8) · 관 45.054 → 47 m 고침 이어 받음 · 노선 밖 고침 버림 · 조정창 옮김 · 같은 목록 안 갈아끼움 · 보이는 카드 수 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/design/design_view_cross.ts | 2 + B06_Section/B06_Section_Cross_Stage.ts | 26 +-- B06_Section/B06_Section_Section_Store.ts | 55 +++++ B06_Section/B06_Section_Station_Keys.ts | 64 +++++- .../B06_Section_UI_Page_Link_Session.ts | 29 ++- B06_Section/B06_Section_UI_Page_Persist.ts | 20 +- .../B06_Section_UI_Page_Station_Swap.ts | 31 +++ B06_Section/B06_Section_UI_Section_View.ts | 9 +- .../B06_Section_UI_Section_View_Lazy.ts | 29 +++ B06_Section/logic/B06_Section_Station_Swap.ts | 182 +++++++++++++++ .../tester/helper_346_2_station_swap.mjs | 211 ++++++++++++++++++ resources/tester/helper_346_2_swap_entry.ts | 25 +++ resources/tester/test_346_2_station_swap.py | 121 ++++++++++ 13 files changed, 765 insertions(+), 39 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Page_Station_Swap.ts create mode 100644 B06_Section/B06_Section_UI_Section_View_Lazy.ts create mode 100644 B06_Section/logic/B06_Section_Station_Swap.ts create mode 100644 resources/tester/helper_346_2_station_swap.mjs create mode 100644 resources/tester/helper_346_2_swap_entry.ts create mode 100644 resources/tester/test_346_2_station_swap.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 8b7cee4fd..066a628c7 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -38,6 +38,7 @@ import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pi import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { paintMapPick } from "./design_pick_profile"; import { watchDrafts } from "./design_structure_status"; +import { followSwaps } from "../../B06_Section/B06_Section_UI_Page_Station_Swap"; import { savingDesign } from "./design_save"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync"; @@ -570,6 +571,7 @@ export async function mountCrossView( host.onDispose(onStaleStructureTypes(() => host.visible() && reloadPanel())); // 바뀜 신호 하나(323-6 · `watchDrafts`) — 초안이 어느 길로 바뀌든 보일 때 카드 · 그림 다시(옛 되부름 갈음) host.onDispose(watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures())); + host.onDispose(followSwaps(projectId, () => currentRouteId, host, refreshDetailForStructures)); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); window.clearTimeout(wallAdjustTimer); diff --git a/B06_Section/B06_Section_Cross_Stage.ts b/B06_Section/B06_Section_Cross_Stage.ts index cde2f0969..bd5247b7f 100644 --- a/B06_Section/B06_Section_Cross_Stage.ts +++ b/B06_Section/B06_Section_Cross_Stage.ts @@ -19,6 +19,9 @@ import type { StageContext, } from "../A00_Common/design/design_pipeline_types"; import { planElevationAt } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import { groundSamples } from "./logic/B06_Section_Station_Swap"; + +export { groundSamples }; /** 측점 하나의 횡단 입력 — 비면 기본(지반 리핑암 · 좌절토 · 표준 측구 · 표준 경사). 확폭은 측점(stations)이 줌. */ export type CrossStationInput = Partial< @@ -31,29 +34,6 @@ export interface CrossStageInput { byKey: Record; } -interface Sample { - offset_m: number; - elevation_m: number | null; - valid: boolean; -} - -/** 지반 표본(중심 → 바깥 · 좌 +) → 엔진 표본(오프셋 오름차순 · 우 −). */ -export function groundSamples(station: PipelineStation): Sample[] { - const ground = station.ground; - if (!ground) return []; - const point = (offset: number, z: number | null): Sample => ({ - offset_m: offset, - elevation_m: z, - valid: z !== null && Number.isFinite(z), - }); - const right = ground.right.map((s) => point(-s.offsetM, s.z)); - const left = ground.left.map((s) => point(s.offsetM, s.z)); - const out = [...right, ...left]; - if (ground.centerZ !== null && !out.some((s) => Math.abs(s.offset_m) < 1e-9)) - out.push(point(0, ground.centerZ)); - return out.sort((a, b) => a.offset_m - b.offset_m); -} - /** 측점 하나의 엔진 입력. */ function optionsFor( station: PipelineStation, diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index a752b2bce..43e57226f 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -30,6 +30,14 @@ import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options"; +import type { PipelineStation } from "../A00_Common/design/design_pipeline_types"; +import { + carryStationKeys, + registeredKeys, + stationIntervalOf, + useStationKeyList, +} from "./B06_Section_Station_Keys"; +import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap"; const cache = new Map(); const pending = new Map>(); @@ -103,6 +111,53 @@ export async function loadSectionDetail( return request; } +/** 새 상세 · 이어 받은 측점의 누가거리 옮김(옛 0.01 m 글 → 새 글 · 조작값 맵을 옮길 때). */ +type DetailListener = (detail: SectionDetailResponse, moves: ReadonlyMap) => void; +const listeners = new Map>(); + +/** 상세 객체가 갈아끼워짐(측점 수가 바뀜 · 346-2 4파) — 쥔 옛 객체를 버리고 새것으로 다시 그릴 것. */ +export function subscribeSectionDetail( + projectId: string, + routeId: number, + fn: DetailListener, +): () => void { + const key = keyOf(projectId, routeId); + const set = listeners.get(key) ?? new Set(); + set.add(fn); + listeners.set(key, set); + return () => set.delete(fn); +} + +/** + * 계산 묶음 측점(가게 `stations`)이 측점 목록을 바꿨으면 상세를 **갈아끼움** — 같은 목록이면 그대로(제자리 + * 갱신 길이 맡음 · null). 앞 횡단은 측점 열쇠로 이어 받고, 노선 모양이 바뀐 차례면 세션 고침도 열쇠로 넘김 + * (사라진 측점 = 「노선 밖 고침 N 건」). 새 상세 = 캐시 · 세션 · 듣는 쪽(횡단 · 종단 보기 · 3D). + */ +export async function swapSectionStations( + projectId: string, + routeId: number, + stations: ReadonlyArray, +): Promise<{ detail: SectionDetailResponse; moves: Map } | null> { + const key = keyOf(projectId, routeId); + const current = cache.get(key); + if (!current || !stations.length) return null; + const interval = stationIntervalOf(current); + const oldKeys = registeredKeys(projectId, routeId, current.cross_sections, interval); + if (sameStationList(current.cross_sections, oldKeys, stations)) return null; + const swap = swapCrossSections(current, stations, oldKeys, interval); + // 새 목록 열쇠 = 묶음 측점이 정한 것 그대로(규칙 한 벌 · 다시 셈하지 않음) + const nextKeys = stations.map((station) => ({ key: station.key, chainageM: station.chainageM })); + if (swap.moved) carryStationKeys(projectId, routeId, nextKeys); + else useStationKeyList(projectId, routeId, nextKeys); + cache.set(key, swap.detail); + writeState("section-detail", swap.detail, projectId, routeId); + await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4) + withStructureAreas(swap.detail); + if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음 + listeners.get(key)?.forEach((fn) => fn(swap.detail, swap.moves)); + return { detail: swap.detail, moves: swap.moves }; +} + /** * 캐시를 비운다. routeId를 생략하면 그 프로젝트 전부 — * 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다. diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index 3962048f3..ffe3cbf28 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -41,7 +41,7 @@ const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"]; const NEAR_M = 0.01; const HINT = "@at"; -type Keyed = { key: string; chainageM: number }; +export type Keyed = { key: string; chainageM: number }; type Raw = Record & { [HINT]?: Record }; const lists = new Map(); @@ -111,21 +111,46 @@ export function stationKeysOf( }); } -/** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */ -export function useStationKeys( - projectId: string, - routeId: number, - detail: Pick, -): void { - const interval = +type KeyedDetail = Pick; + +/** 측점 간격 — 종단 정책 → 없으면 측점에서 짐작. */ +export function stationIntervalOf(detail: KeyedDetail): number { + return ( ( detail.longitudinal?.profile_alignment as { policy?: { station_interval_m?: number } } | undefined - )?.policy?.station_interval_m ?? inferStationInterval(detail.cross_sections); - lists.set(scopeOf(projectId, routeId), stationKeysOf(projectId, detail.cross_sections, interval)); + )?.policy?.station_interval_m ?? inferStationInterval(detail.cross_sections) + ); +} + +/** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */ +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); current = { projectId, routeId }; } +/** 열쇠 목록을 그대로 세움 — 계산 묶음 측점(`PipelineStation.key`)이 정한 열쇠(346-2 4파). */ +export function useStationKeyList(projectId: string, routeId: number, list: Keyed[]): void { + lists.set(scopeOf(projectId, routeId), list); + current = { projectId, routeId }; +} + +/** + * 지금 상세 측점마다 열쇠 — 세워 둔 목록(그 자리 0.01 m) → 없으면 규칙으로 셈. 관 장부가 이미 새 자리로 + * 옮긴 뒤에도(옮긴 관) 옛 측점은 세울 때의 열쇠로 찾음. + */ +export function registeredKeys( + projectId: string, + routeId: number, + sections: ReadonlyArray, + intervalM: number | null, +): string[] { + const list = lists.get(scopeOf(projectId, routeId)); + const computed = stationKeysOf(projectId, sections, intervalM); + return sections.map((s, i) => (list && nearest(list, s.chainage_m)?.key) ?? computed[i].key); +} + /** 지금 노선(마지막으로 세운 목록) — 세션 열쇠만 아는 저장소(암 경계 · 암 경사)가 씀. */ export const currentStationScope = () => current; @@ -261,6 +286,25 @@ export function carryStationDrafts( return outside.size; } +/** 잠시 쓰는 새 목록 자리 — 같은 노선 안에서 목록이 갈릴 때. */ +const NEXT_ROUTE = -1; + +/** + * 같은 노선에서 노선 모양이 바뀌어 측점 목록이 갈림(346-2 4파 · 상세 갈아끼움) — 지금 목록 고침을 새 목록에 + * 넘기고(넘김 규칙 = `carryStationDrafts`) 새 목록을 세움. 돌려주는 값 = 노선 밖으로 나간 고침 수. + */ +export function carryStationKeys(projectId: string, routeId: number, next: Keyed[]): number { + lists.set(scopeOf(projectId, NEXT_ROUTE), next); + const outside = carryStationDrafts(projectId, routeId, NEXT_ROUTE); + for (const name of KEYED_STATES) { + writeState(name, readRaw(name, projectId, NEXT_ROUTE), projectId, routeId); + writeState(name, null, projectId, NEXT_ROUTE); + } + lists.delete(scopeOf(projectId, NEXT_ROUTE)); + useStationKeyList(projectId, routeId, next); + return outside; +} + /** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */ function carriedValue(name: KeyedState, value: unknown): unknown { if (CARRY_WHOLE.includes(name)) return value; diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index edafcd95a..3544a19d6 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -26,6 +26,7 @@ import { } from "./B06_Section_UI_Cross_Culvert_Wire"; import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc"; +import { renameChainageKeys } from "./logic/B06_Section_Station_Swap"; /** 세션에 담기는 측점별 조작값 한 겹 맵. */ export interface SessionMap { @@ -40,12 +41,36 @@ export interface SessionMap { * 세션 보관 맵을 만든다. `accept` 가 항목마다 값을 검증·정규화해 돌려주고, * `undefined` 를 내면 그 항목은 버린다(손상값·옛 형식 정리 자리). */ +/** 만든 조작값 맵 → 세션 열쇠 — 측점 목록을 갈아끼우면 누가거리 열쇠를 새 자리로 옮김(346-2 4파). */ +const alive = new Map, () => string | null>(); + +/** + * 측점이 움직임(이어 받은 측점의 누가거리가 바뀜 · `옛 0.01 m 글 → 새 글`) — 조정창 값(반폭 · 기슭막이 · + * 유입 형식 · 집수정 · 추가 벽 · 단별 구간값)을 새 자리로 옮겨 세션에 씀. 빠진 측점 값은 그대로 둠. + */ +export function moveSessionMaps(moves: ReadonlyMap): void { + if (!moves.size) return; + // 같은 세션 칸은 마지막에 만든 맵 하나만(떠난 보기의 옛 맵이 지금 값을 덮지 않게) + const latest = new Map>(); + alive.forEach((key, map) => { + const at = key(); + if (at) latest.set(at, map); + }); + for (const map of latest.values()) { + map.load(); + const next = renameChainageKeys(map.values, moves); + map.values.clear(); + next.forEach((value, key) => map.values.set(key, value)); + map.persist(); + } +} + export function createSessionMap( sessionKey: () => string | null, accept: (value: unknown) => V | undefined, ): SessionMap { const values = new Map(); - return { + const map: SessionMap = { values, load(): void { values.clear(); @@ -73,6 +98,8 @@ export function createSessionMap( } }, }; + alive.set(map as SessionMap, sessionKey); + return map; } export interface LinkFlagMaps { diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 8c0c1b2e1..3580cdb6f 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -75,6 +75,18 @@ export function readCutSlopeSession(projectId: string, routeId: number): Record< return Object.fromEntries(readKeyed("cutslope", projectId, routeId)); } +/** 지금 사는 암 경계 · 암 경사 저장소 — 측점 목록을 갈아끼우면 새 목록 누가거리로 다시 읽음(346-2 4파). */ +const keyedStores = new Set<{ load: () => void }>(); +function keepAlive void }>(store: T): T { + keyedStores.add(store); + return store; +} + +/** 측점 목록이 갈아끼워짐 — 암 경계 · 암 경사 값을 측점 열쇠 세션에서 다시 읽음. */ +export function reloadKeyedStores(): void { + keyedStores.forEach((store) => store.load()); +} + /** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */ function keyedScope(storageKey: string): { projectId: string; routeId: number } | null { const [projectId, route] = storageKey.split(":").slice(-2); @@ -249,7 +261,7 @@ export function createRockBoundaryStore(options: { } } - return { + return keepAlive({ offsets, load(): void { offsets.clear(); @@ -297,7 +309,7 @@ export function createRockBoundaryStore(options: { recompute(chainageM); }, }, - }; + }); } /** 측점별 암 절토 경사비 저장소 — 값(Map)과 카드 제어기를 함께 낸다(2026-09-07). */ @@ -349,7 +361,7 @@ export function createCutSlopeStore(options: { } } - return { + return keepAlive({ ratios, load(): void { ratios.clear(); @@ -384,7 +396,7 @@ export function createCutSlopeStore(options: { recompute(chainageM); }, }, - }; + }); } /** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */ diff --git a/B06_Section/B06_Section_UI_Page_Station_Swap.ts b/B06_Section/B06_Section_UI_Page_Station_Swap.ts new file mode 100644 index 000000000..a53ff4132 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Station_Swap.ts @@ -0,0 +1,31 @@ +/* ============================================================================= + * B06_Section_UI_Page_Station_Swap.ts + * 횡단 보기가 계산 묶음 측점을 따라감(PLAN 346-2 4파) — 가게 `stations` 가 측점 목록을 바꾸면(늘고 줄면) + * 상세 객체를 갈아끼우고(`swapSectionStations`) 조정창 값 · 암 경계 · 암 경사를 새 자리로 옮긴 뒤 보일 때 카드 · + * 종단도를 다시(카드는 보이는 것 먼저 · 다시 셈은 그 길이 맡음 · 안 보이면 다시 보일 때 그림). + * ========================================================================== */ + +import { useDesignStore } from "../A00_Common/design/design_store"; +import { swapSectionStations } from "./B06_Section_Section_Store"; +import { moveSessionMaps } from "./B06_Section_UI_Page_Link_Session"; +import { reloadKeyedStores } from "./B06_Section_UI_Page_Persist"; + +/** 묶음 측점을 따라감 — 돌려준 함수로 끊음. `redraw` = 캐시에서 상세를 다시 읽어 카드 · 그림 다시. */ +export function followSwaps( + projectId: string | null, + routeId: () => number | null, + host: { visible: () => boolean }, + redraw: () => Promise, +): () => void { + if (!projectId) return () => undefined; + return useDesignStore(projectId, null).subscribe("stations", (stations) => { + const route = routeId(); + if (route === null || !stations?.length) return; + void swapSectionStations(projectId, route, stations).then((swap) => { + if (!swap) return; // 같은 목록 — 제자리 갱신 길이 맡음 + moveSessionMaps(swap.moves); // 조정창 값(반폭 · 기슭막이 · 유입 · 집수정 · 추가 벽) + reloadKeyedStores(); // 암 경계 · 암 경사 = 측점 열쇠 세션 → 새 목록 누가거리로 + if (host.visible()) void redraw(); + }); + }); +} diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 32fd338c1..f2bb7aab8 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -44,6 +44,7 @@ import { } from "./B06_Section_UI_Cross_View_Metrics"; import { cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; import { CROSS_GRID_GAP, CROSS_HEIGHT, createIdleQueue } from "./B06_Section_UI_Section_Common"; +import { eagerCardCount, pendingCard } from "./B06_Section_UI_Section_View_Lazy"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; @@ -477,9 +478,14 @@ export function createSectionView( cachedCardWidth, cols, ); + // 보이는 카드 먼저(346-2) — 나머지는 빈 자리 · 쉬는 틈에 진짜 카드(`_Section_View_Lazy`) + const eager = eagerCardCount(cards, cols, rowHeights[0] ?? CROSS_HEIGHT, CROSS_GRID_GAP); sections.forEach((section, index) => { cachedRowHeight.set(section.station_id, rowHeights[index]); - grid.append(buildCrossCard(section, rowHeights[index], plotBases[index])); + if (index < eager) + grid.append(buildCrossCard(section, rowHeights[index], plotBases[index])); + else grid.append(pendingCard(section.station_id, rowHeights[index])); + if (index >= eager) offscreen.queue.add(section.chainage_m); }); } else { grid.append(emptyView(L("B06_Profile_View_NoCross"))); @@ -488,6 +494,7 @@ export function createSectionView( const keepScrollTop = cards.scrollTop; cards.replaceChildren(fillSlopeNotice.root, grid); cards.scrollTop = keepScrollTop; + offscreen.drain(); syncProfile(); }; diff --git a/B06_Section/B06_Section_UI_Section_View_Lazy.ts b/B06_Section/B06_Section_UI_Section_View_Lazy.ts new file mode 100644 index 000000000..7547645b2 --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_Lazy.ts @@ -0,0 +1,29 @@ +/* ============================================================================= + * B06_Section_UI_Section_View_Lazy.ts + * 횡단 카드 목록 통째 그리기에서 **보이는 카드 먼저**(PLAN 346-2 4파 · 측점 수가 바뀌어 목록을 다시 짤 때) — + * 보이는 줄 + 한 줄만 바로 그리고 나머지는 같은 id · 같은 높이 빈 자리로 두었다가 쉬는 틈에 진짜 카드로 + * (`createIdleQueue` · 화면 밖 카드 갈아 끼우기와 같은 줄). 스크롤 자리 · 측점 찾기는 빈 자리로도 그대로. + * ========================================================================== */ + +/** 처음에 바로 그릴 카드 수 — 보이는 줄 + 한 줄 · 칸이 아직 안 보이면(높이 0) 두 줄. */ +export function eagerCardCount( + view: { clientHeight: number; scrollTop: number }, + cols: number, + rowHeightPx: number, + gapPx: number, +): number { + const rows = + view.clientHeight > 0 + ? Math.ceil((view.scrollTop + view.clientHeight) / Math.max(rowHeightPx + gapPx, 1)) + 1 + : 2; + return rows * Math.max(cols, 1); +} + +/** 아직 안 그린 카드 자리 — 진짜 카드와 같은 id · 높이. */ +export function pendingCard(stationId: string, heightPx: number): HTMLElement { + const slot = document.createElement("article"); + slot.id = `cross-${stationId}`; + slot.className = "b06-cross-card b06-cross-card--pending"; + slot.style.height = `${heightPx}px`; + return slot; +} diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts new file mode 100644 index 000000000..99b79d875 --- /dev/null +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -0,0 +1,182 @@ +/* ============================================================================= + * B06_Section_Station_Swap.ts + * 측점 수가 바뀔 때 횡단 목록 갈아끼움(PLAN 346-2 4파) — 계산 묶음 측점(`PipelineStation[]`) → 새 상세 객체. + * + * · 같은 측점(열쇠 · 없으면 같은 자리 같은 종류)은 앞 횡단을 이어 받음 — 설계 · 구조물 제원 · 이름 그대로 · + * 자리(누가거리 · 좌표 · 방위 · 확폭)와 지반 표본(채워 왔으면)만 새것. + * · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움). + * · 빠진 측점 = 목록에서 뺌(세션 고침은 열쇠로 남음 · 노선이 바뀐 때만 「노선 밖 고침」 · 부르는 쪽). + * · 옛 상세는 그대로 둠 — 새 객체를 돌려줌(쥔 쪽이 갈아끼움). + * + * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` `format_station` ↔ `formatStation`. + * 거울 시험 `resources/tester/test_346_2_station_swap.py`. + * 순수 계산(DOM · fetch · 세션 없음). + * ========================================================================== */ + +import type { PipelineStation } from "../../A00_Common/design/design_pipeline_types"; +import type { CrossSection, SectionDetailResponse, SectionStation } from "../B06_Section_Api_Fetch"; + +interface Sample { + offset_m: number; + elevation_m: number | null; + valid: boolean; +} + +/** 지반 표본(중심 → 바깥 · 좌 +) → 엔진 표본(오프셋 오름차순 · 우 −). */ +export function groundSamples(station: PipelineStation): Sample[] { + const ground = station.ground; + if (!ground) return []; + const point = (offset: number, z: number | null): Sample => ({ + offset_m: offset, + elevation_m: z, + valid: z !== null && Number.isFinite(z), + }); + const right = ground.right.map((s) => point(-s.offsetM, s.z)); + const left = ground.left.map((s) => point(s.offsetM, s.z)); + const out = [...right, ...left]; + if (ground.centerZ !== null && !out.some((s) => Math.abs(s.offset_m) < 1e-9)) + out.push(point(0, ground.centerZ)); + return out.sort((a, b) => a.offset_m - b.offset_m); +} + +/** 짝: `format_station` — `STA.k+mmm.mmm`. */ +export function formatStation(chainageM: number): string { + const at = Math.max(chainageM, 0); + const km = Math.floor(at / 1000); + const rest = (at - km * 1000).toFixed(3).padStart(7, "0"); + return `STA.${km}+${rest}`; +} + +/** 서버 측점 id 꼴(`station_000000484210` = 누가거리 mm 12자리). */ +export const stationIdOf = (chainageM: number): string => + `station_${String(Math.round(chainageM * 1000)).padStart(12, "0")}`; + +/** 새 측점의 기본 설계 — 계획고 0 이라 늘 낡음 → 다시 셈이 세션 선택 · 기본값으로 채움. */ +const freshDesign = () => + ({ + design_elevation_m: 0, + ground_type: "ripping_rock", + section_mode: "left_cut", + ditch_type: "standard", + }) as unknown as NonNullable; + +function kindOf(station: PipelineStation): SectionStation["kind"] { + if (station.kind === "end") return station.key.endsWith("EP") ? "ep" : "bp"; + return station.kind === "grid" ? "regular" : "irregular"; +} + +/** 같은 목록인가 — 열쇠 · 누가거리가 차례대로 같음(제자리 갱신이면 갈아끼우지 않음). */ +export function sameStationList( + sections: ReadonlyArray<{ chainage_m: number }>, + oldKeys: ReadonlyArray, + stations: ReadonlyArray, +): boolean { + return ( + sections.length === stations.length && + stations.every( + (station, index) => + Math.abs(station.chainageM - sections[index].chainage_m) < 1e-6 && + (oldKeys[index] === station.key || kindOf(station) === "regular"), + ) + ); +} + +/** 조작값 맵 열쇠(`85.05` · `85.05:outlet` · `b85.05`)의 누가거리를 새 자리로 — 안 옮기는 열쇠는 그대로. */ +export function renameChainageKeys( + values: ReadonlyMap, + moves: ReadonlyMap, +): Map { + const out = new Map(); + for (const [key, value] of values) { + const match = /^(b?)(-?\d+\.\d{2})(:.*)?$/.exec(key); + const to = match && moves.get(match[2]); + out.set(to ? `${match[1]}${to}${match[3] ?? ""}` : key, value); + } + return out; +} + +export interface StationSwap { + detail: SectionDetailResponse; + added: string[]; + removed: string[]; + /** 이어 받은 측점 중 누가거리가 바뀐 것 — 옛 0.01 m 글 → 새 글(조작값 맵 옮김). */ + moves: Map; + /** 이어 받은 측점 중 좌표가 0.01 m 넘게 움직인 것이 있나 — 노선이 바뀐 차례(「노선 밖 고침」 셈). */ + moved: boolean; +} + +/** + * 새 측점 목록으로 상세를 새로 짬. `oldKeys` = 지금 상세 측점마다 열쇠(같은 차례 · `stationKeysOf`) · + * 열쇠가 안 맞으면 같은 자리(0.01 m) · 같은 종류의 앞 횡단을 씀. + */ +export function swapCrossSections( + detail: SectionDetailResponse, + stations: ReadonlyArray, + oldKeys: ReadonlyArray, + intervalM: number | null, +): StationSwap { + const byKey = new Map(detail.cross_sections.map((section, i) => [oldKeys[i], section])); + const used = new Set(); + let moved = false; + const added: string[] = []; + const moves = new Map(); + const text = (chainageM: number) => chainageM.toFixed(2); + const priorOf = (station: PipelineStation): CrossSection | undefined => { + const hit = byKey.get(station.key); + if (hit && !used.has(hit)) return hit; + return detail.cross_sections.find( + (section) => + !used.has(section) && + section.kind === kindOf(station) && + Math.abs(section.chainage_m - station.chainageM) < 0.01, + ); + }; + const longStations: SectionStation[] = []; + const cross_sections = stations.map((station): CrossSection => { + 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) + moved = true; + if (prior && text(prior.chainage_m) !== text(station.chainageM)) + moves.set(text(prior.chainage_m), text(station.chainageM)); + const kind = kindOf(station); + const onGrid = + kind === "irregular" && + !!intervalM && + Math.abs(station.chainageM / intervalM - Math.round(station.chainageM / intervalM)) < 1e-6; + const place = { + station_id: prior?.station_id ?? stationIdOf(station.chainageM), + chainage_m: station.chainageM, + label: formatStation(station.chainageM), + kind, + ...(onGrid ? { grid: true } : {}), + center_z: station.ground?.centerZ ?? prior?.center_z ?? null, + azimuth_deg: station.azimuthDeg, + plan_radius_m: station.planRadiusM, + curve_outer_side: station.curveOuterSide, + curve_widening_m: station.curveWideningM, + center_x: station.x, + center_y: station.y, + frame: { left_xy: [station.left[0], station.left[1]] as [number, number] }, + }; + longStations.push({ ...place, ...(prior?.structure ? { structure: prior.structure } : {}) }); + const samples = station.ground ? groundSamples(station) : (prior?.samples ?? []); + return prior ? { ...prior, ...place, samples } : { ...place, samples, design: freshDesign() }; + }); + const removed = detail.cross_sections + .map((section, i) => (used.has(section) ? null : oldKeys[i])) + .filter((key): key is string => key !== null); + const last = stations[stations.length - 1]?.chainageM ?? detail.longitudinal.length_m; + return { + detail: { + ...detail, + longitudinal: { ...detail.longitudinal, stations: longStations, length_m: last }, + cross_sections, + }, + added, + removed, + moved, + moves, + }; +} diff --git a/resources/tester/helper_346_2_station_swap.mjs b/resources/tester/helper_346_2_station_swap.mjs new file mode 100644 index 000000000..9458927f0 --- /dev/null +++ b/resources/tester/helper_346_2_station_swap.mjs @@ -0,0 +1,211 @@ +/* 346-2 4파 헬퍼 — 측점 셋 늘고 둘 빠진 목록으로 상세 갈아끼움 · 카드 수 · 열쇠 이어 받기 · 보이는 카드 먼저. + * node helper_346_2_station_swap.mjs <묶음.mjs> + * vite ssr 로 묶은 진짜 모듈(helper_346_2_swap_entry.ts) · 표준출력 JSON 한 벌. + */ +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); +const toasts = []; +globalThis.__toasts = toasts; + +const m = await import(pathToFileURL(process.argv[2]).href); +const P = "p346"; +const R = 9; +const out = {}; + +/* 옛 목록 7 — 시점 · 격자 넷 · 관 하나 · 종점(간격 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, mark: `old-${chainage_m}` }, + ...extra, +}); +const oldDetail = () => ({ + 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 OLD_KEYS = ["end:BP", "grid:1", "grid:2", "pipe:pipe-s1", "grid:3", "grid:4", "end:EP"]; + +/* 새 목록 8 — 셋 늘고(st-1 · st-2 · st-3) 둘 빠짐(grid:2 · grid:4) · 관 47 m 로 · 종점 90 m 로 */ +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, +}); +const NEW = (dy) => [ + station("end:BP", 0, "end", null, dy), + station("grid:1", 20, "grid", null, dy), + station("pipe:pipe-s1", 47, "pipe", "pipe-s1", dy), + station("structure:st-1", 52, "structure", "st-1", dy), + station("grid:3", 60, "grid", null, dy), + station("structure:st-2", 65, "structure", "st-2", dy), + station("structure:st-3", 75, "structure", "st-3", dy), + station("end:EP", 90, "end", null, dy), +]; + +/* 1. 순수 갈아끼움 */ +{ + const before = oldDetail(); + const swap = m.swapCrossSections(before, NEW(0), OLD_KEYS, 20); + out.pure = { + count: swap.detail.cross_sections.length, + longCount: swap.detail.longitudinal.stations.length, + added: swap.added, + removed: swap.removed, + moves: Object.fromEntries(swap.moves), + moved: swap.moved, + keptMark: swap.detail.cross_sections.map((s) => s.design?.mark ?? null), + kinds: swap.detail.cross_sections.map((s) => s.kind), + labels: [swap.detail.cross_sections[2].label, swap.detail.cross_sections[7].label], + samples: swap.detail.cross_sections[1].samples.map((s) => s.offset_m), + oldUntouched: + before.cross_sections.length === 7 && before.cross_sections[3].chainage_m === 45.054, + length: swap.detail.longitudinal.length_m, + same: m.sameStationList(before.cross_sections, OLD_KEYS, NEW(0)), + sameSelf: m.sameStationList( + swap.detail.cross_sections, + NEW(0).map((s) => s.key), + NEW(0), + ), + }; + out.format = [0, 45.054, 1234.5].map((c) => m.formatStation(c)); +} + +/* 2. 가게 → 상세 갈아끼움 · 열쇠 이어 받기 · 노선 밖 고침 */ +{ + m.writeState("section-detail", oldDetail(), P, R); + m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 45.054 }] }, P); + const first = await m.loadSectionDetail(P, R); // 세션 한 벌 → 캐시 · 측점 열쇠 세움 + const choice = (ground) => ({ + ground_type: ground, + section_mode: "right_cut", + ditch_side: "right", + }); + m.writeCrossDesignChoice(P, R, 20, choice("soil")); + m.writeCrossDesignChoice(P, R, 40, choice("soil")); + m.writeCrossDesignChoice(P, R, 45.054, choice("blasting_rock")); + const widths = m.createSessionMap( + () => "aislo:test:crossw", + (v) => v, + ); + widths.values.set("45.05", 12); + widths.values.set("45.05:outlet", 3); + widths.values.set("20.00", 8); + widths.persist(); // 조정창은 바꿀 때마다 세션에 씀(제어기와 같은 차례) + const heard = []; + // 관 · 구조물 자리 장부 — 새 목록에서 관은 47 m · 구조물 id 는 측점 계획이 줌 + m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P); + m.useStructureIds(P, [ + { structure_id: "st-1", chainage_m: 52 }, + { structure_id: "st-2", chainage_m: 65 }, + { structure_id: "st-3", chainage_m: 75 }, + ]); + const stopListen = m.subscribeSectionDetail(P, R, (detail, moves) => + heard.push({ count: detail.cross_sections.length, moves: Object.fromEntries(moves) }), + ); + let redraws = 0; + const stop = m.followSwaps( + P, + () => R, + { visible: () => true }, + async () => { + redraws += 1; + }, + ); + m.useDesignStore(P, null).set("stations", NEW(0.5)); // 좌표가 움직임 = 노선 모양 바뀜 + await new Promise((resolve) => setTimeout(resolve, 30)); + const now = await m.loadSectionDetail(P, R); + out.store = { + replaced: now !== first, + count: now.cross_sections.length, + heard, + choices: [...m.crossDesignChoices(P, R).entries()] + .sort((a, b) => a[0] - b[0]) + .map(([at, c]) => [at, c.ground_type, c.section_mode ?? null]), + rawKeys: Object.keys(m.readState("crossdesign", P, R) ?? {}) + .filter((k) => k !== "@at") + .sort(), + widths: Object.fromEntries(widths.values), + redraws, + }; + // 같은 목록을 다시 내면 갈아끼우지 않음 + const again = await m.swapSectionStations(P, R, NEW(0.5)); + out.store.again = again === null; + stop(); + stopListen(); +} + +/* 3. 보이는 카드 먼저 — 바로 그릴 수 */ +out.eager = [ + m.eagerCardCount({ clientHeight: 600, scrollTop: 0 }, 3, 250, 12), + m.eagerCardCount({ clientHeight: 600, scrollTop: 1310 }, 3, 250, 12), + m.eagerCardCount({ clientHeight: 0, scrollTop: 0 }, 2, 250, 12), +]; +out.rename = Object.fromEntries( + m.renameChainageKeys( + new Map([ + ["45.05", 1], + ["b45.05", 2], + ["45.05:outlet", 3], + ["60.00", 4], + ]), + new Map([["45.05", "47.00"]]), + ), +); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_346_2_swap_entry.ts b/resources/tester/helper_346_2_swap_entry.ts new file mode 100644 index 000000000..29be9449b --- /dev/null +++ b/resources/tester/helper_346_2_swap_entry.ts @@ -0,0 +1,25 @@ +/* 346-2 4파 시험 묶음 진입점 — 측점 수가 바뀔 때 횡단 목록 갈아끼움(진짜 모듈 · vite ssr). */ +export { + formatStation, + renameChainageKeys, + sameStationList, + swapCrossSections, +} from "../../B06_Section/logic/B06_Section_Station_Swap"; +export { eagerCardCount } from "../../B06_Section/B06_Section_UI_Section_View_Lazy"; +export { + loadSectionDetail, + subscribeSectionDetail, + swapSectionStations, +} from "../../B06_Section/B06_Section_Section_Store"; +export { followSwaps } from "../../B06_Section/B06_Section_UI_Page_Station_Swap"; +export { + crossDesignChoices, + writeCrossDesignChoice, +} from "../../B06_Section/B06_Section_Cross_Design_Session"; +export { useStructureIds } from "../../B06_Section/B06_Section_Station_Keys"; +export { + createSessionMap, + moveSessionMaps, +} from "../../B06_Section/B06_Section_UI_Page_Link_Session"; +export { useDesignStore } from "../../A00_Common/design/design_store"; +export { readState, writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/test_346_2_station_swap.py b/resources/tester/test_346_2_station_swap.py new file mode 100644 index 000000000..df0af3dbc --- /dev/null +++ b/resources/tester/test_346_2_station_swap.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""346-2 4파 — 측점 수가 바뀔 때 횡단 목록 갈아끼움. + +못박는 것 + - 측점 셋 늘고 둘 빠진 목록 → 새 상세 객체(카드 8 · 종단 측점 8) · 이어 받은 측점은 설계 그대로 · + 새 측점은 지반 표본 + 기본 설계 · 옛 상세는 그대로 · 같은 목록이면 안 갈아끼움. + - 가게 `stations` → 캐시 상세 갈아끼움 · 듣는 쪽에 새 상세 + 누가거리 옮김 · + 카드 선택은 측점 열쇠로 새 자리(관 45.054 → 47 m) · 노선 모양이 바뀌면 넘김 규칙 + (단면 유형 버림) · 사라진 측점 고침 버림 · 조정창 값도 옮긴 자리로. + - 보이는 카드 먼저 — 보이는 줄 + 한 줄만 바로. + - `formatStation` ↔ 파이썬 `format_station` 같은 글. + +Node 헬퍼 `helper_346_2_station_swap.mjs` 가 vite ssr 로 묶은 진짜 모듈 +(`helper_346_2_swap_entry.ts`)을 부름. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Sections_Core import format_station # noqa: E402 + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b346") + build = subprocess.run( + [ + "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( + ["node", str(HERE / "helper_346_2_station_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) + + +def test_셋_늘고_둘_빠진_목록으로_갈아끼움(out): + pure = out["pure"] + assert pure["count"] == 8 and pure["longCount"] == 8 + assert pure["added"] == ["structure:st-1", "structure:st-2", "structure:st-3"] + assert pure["removed"] == ["grid:2", "grid:4"] + assert pure["moves"] == {"45.05": "47.00", "100.00": "90.00"} + assert pure["keptMark"] == [ + "old-0", + "old-20", + "old-45.054", + None, + "old-60", + None, + None, + "old-100", + ] + assert pure["kinds"] == [ + "bp", + "regular", + "irregular", + "irregular", + "regular", + "irregular", + "irregular", + "ep", + ] + assert pure["labels"] == ["STA.0+047.000", "STA.0+090.000"] + assert pure["samples"] == [-0.5, 0, 0.5] + assert pure["oldUntouched"] is True and pure["length"] == 90 + assert pure["same"] is False and pure["sameSelf"] is True + + +def test_측점_글_파이썬과_같음(out): + assert out["format"] == [format_station(c) for c in (0, 45.054, 1234.5)] + + +def test_가게_측점으로_상세_갈아끼움_열쇠로_이어_받기(out): + store = out["store"] + assert store["replaced"] is True and store["count"] == 8 + assert store["heard"] == [{"count": 8, "moves": {"45.05": "47.00", "100.00": "90.00"}}] + # 관 고침은 47 m 로 · 노선 모양이 바뀌어 단면 유형은 버림 · 40 m(grid:2) 고침은 노선 밖 + assert store["choices"] == [[20, "soil", None], [47, "blasting_rock", None]] + assert store["rawKeys"] == ["grid:1", "pipe:pipe-s1"] + assert store["widths"] == {"47.00": 12, "47.00:outlet": 3, "20.00": 8} + assert store["redraws"] == 1 # 보이는 횡단 보기 = 카드 · 그림 한 번 다시 + assert store["again"] is True # 같은 목록 = 안 갈아끼움 + + +def test_보이는_카드_먼저(out): + assert out["eager"] == [12, 27, 4] # 3열 · 600 px · (250+12) px 줄 = 3 줄 + 1 + assert out["rename"] == {"47.00": 1, "b47.00": 2, "47.00:outlet": 3, "60.00": 4}