From 24fe93ba6b209d20e84f39987fdcace7d1f76fbf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:07:37 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05=20=ED=8F=89=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EB=A7=88=EC=BB=A4):=20=EB=A7=88=EC=BB=A4?= =?UTF-8?q?=20=EB=A7=A8=20=EC=9C=84=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EA=B3=B3=20=EB=88=84=EB=A6=84=20=C2=B7=20Esc=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=ED=92=82=20=C2=B7=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20?= =?UTF-8?q?=ED=8C=90=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=C2=B7=20=EA=B4=80=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=ED=9A=A1=EB=8B=A8=20(409=20=C2=B7=20411-A?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마커 그림 = 맨 마지막(교각점 · 손잡이 · 맴돔 위) · 누름 · 맴돔 · 커서 = 재기 다음 마커 먼저 · 마커 누름 = 고른 교각점 풂 - 마커 아닌 곳 누름 · Esc = 공용 고름을 b05-plan 으로 비움(오른쪽 횡단 판 그대로) - 오른쪽 판 = 구조물 · 관 고름도 그 측점 횡단 · 묶음 뒤 열쇠로 붙듦(옮긴 뒤 새 자리) - 시험 399 차례 고쳐 씀 · 새 409 · 411-A Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 27 +-- .../B05_Profile_UI_RouteEdit_Pointer.ts | 26 ++- .../B05_Profile_UI_RouteEdit_Render.ts | 2 +- ...B05_Profile_UI_RouteEdit_StructureMarks.ts | 31 +++- .../helper_399_plan_structure_marks.cjs | 41 ++++- .../tester/helper_411_a_cross_structure.cjs | 159 ++++++++++++++++++ .../tester/test_399_plan_structure_marks.py | 15 +- .../tester/test_409_mark_top_deselect.py | 76 +++++++++ .../tester/test_411_a_cross_structure.py | 58 +++++++ 9 files changed, 401 insertions(+), 34 deletions(-) create mode 100644 resources/tester/helper_411_a_cross_structure.cjs create mode 100644 resources/tester/test_409_mark_top_deselect.py create mode 100644 resources/tester/test_411_a_cross_structure.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index d693bcd98..517b04eb4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -14,7 +14,8 @@ * 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * * 고름 연동(382-2) — 공용 고름 한 칸(`design_selection`) 구독: 일반측점 고름 = 그 측점 횡단 · 평면 법선 · - * 구조물 고름 = 평면 그 자리 마름모만 · 평면 눈금 클릭 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름). + * 구조물 · 관 고름 = 그 측점 횡단(411-A · 법선은 평면 마커 몫) · 옮겨도 열쇠로 따라감 · + * 평면 눈금 클릭 · 마커 고름 풂 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름 = 판 그대로). * 보는 측점 = 측점 열쇠(grid:k) — 묶음 차례마다 열쇠로 다시 짚음(노선을 고쳐도 같은 측점). * 파란 법선 폭 = 그 측점 발자국 기록(가게 `footprint.records`) 비탈 끝 좌 · 우 · 깎기 여부(색). * ========================================================================== */ @@ -74,7 +75,7 @@ export interface CrossWatch { /** 비탈 끝 깎기(절토) 여부 — 발자국 기록이 없으면 없음(옛 파랑 · 반폭). */ leftCut?: boolean; rightCut?: boolean; - /** 구조물 · 관 자리 — 법선 없이 마름모만. */ + /** 구조물 · 관 자리 — 법선은 평면 마커가 그림(399). */ structure?: true; } @@ -90,7 +91,7 @@ export interface CrossPreviewWindow { open: (chainageM: number) => Promise; /** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */ refresh: () => Promise; - /** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 마름모. 고른 것 없으면 null. */ + /** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 `structure`. 고른 것 없으면 null. */ watched: () => CrossWatch | null; } @@ -247,7 +248,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind params.side.append(current.root, previous.root); const interval = (): number => params.request().station_interval_m; - /** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 고름(마름모만). */ + /** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 · 관 측점인가. */ let watching: { key: string | null; chainageM: number; structure: boolean } | null = null; /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */ let shown: CrossPreviewResponse | null = null; @@ -269,7 +270,9 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind } function redraw(): void { - if (watching === null || watching.structure) return; + if (watching === null) return; + const station = located(); + if (station && watching.key === null) watching.key = station.key; // 묶음 뒤 열쇠로 붙듦(옮겨도 따라감) const route = pipe?.input("route") ?? null; if (route !== drawnRoute && shown) previous.show( @@ -284,7 +287,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind }, ); drawnRoute = route; - const at = located()?.chainageM ?? watching.chainageM; + const at = station?.chainageM ?? watching.chainageM; shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId)); shownSource = shown && routeCrossSource(params.projectId, pipe, shown.chainage_m, "cur"); if (shown) current.show(shown, interval(), shownSource); @@ -316,11 +319,11 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind }; /** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */ - function showStation(key: string | null, chainageM: number): void { + function showStation(key: string | null, chainageM: number, structure = false): void { // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. previous.show(null, interval()); - watching = { key, chainageM, structure: false }; + watching = { key, chainageM, structure }; drawnRoute = pipe?.input("route") ?? null; shown = null; redraw(); @@ -328,7 +331,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind if (panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1) } - /** 다른 보기(종단 · 3D · 목록 · 횡단 카드) 고름 → 일반측점이면 그 측점 횡단 · 구조물이면 마름모. */ + /** 다른 보기(종단 · 3D · 목록 · 횡단 카드 · 평면 마커) 고름 → 그 측점 횡단(구조물 · 관도 · 411-A). */ function follow(selection: DesignSelection): void { attach(); const at = selection.chainageM; @@ -341,10 +344,8 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind return; } const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM); - if (!selection.structureId && (!station || isGridStation(station))) - return showStation(station?.key ?? null, station?.chainageM ?? at); - watching = { key: station?.key ?? null, chainageM: station?.chainageM ?? at, structure: true }; - params.onShown?.(); + const structure = !!selection.structureId || (!!station && !isGridStation(station)); + showStation(station?.key ?? null, station?.chainageM ?? at, structure); } const stopPick = subscribeSelection(params.projectId, (selection, from) => { if (from !== PLAN_PICK_FROM) follow(selection); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index b9c89ec5e..358f29407 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -192,7 +192,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP let dragMoved = false; /** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */ let dragRaw: Vertex[] | null = null; - /** 구조물 측점 마커(399) — 집기 차례 = 교각점 · 손잡이 다음 · 끌기 · 방향키 1 m. */ + /** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */ const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); canvas.addEventListener("pointerdown", (event) => { @@ -206,6 +206,17 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP } const state = params.state(); hover(null); // 누르는 동안 · 끄는 동안 맴돔 끔(381-3) + // 구조물 마커가 먼저(409) — 교각점 · 손잡이와 겹쳐도 마커 · 고른 교각점은 풂(마커 방향키 몫) + if (marks.press(px, py)) { + if (state.picked >= 0) { + params.setPicked(-1); + params.syncCurveBar(); + params.draw(); + } + canvas.setPointerCapture(event.pointerId); + return; + } + marks.deselect(); // 마커 아닌 곳 = 마커 고름 풂(오른쪽 횡단 판은 그대로) const pick = pickAt(px, py); // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 @@ -239,8 +250,6 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP })() ) { /* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */ - } else if (marks.press(px, py)) { - /* 구조물 마커를 골랐다 — 끌면 노선 따라 1 m 씩(399). */ } else if ( // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. (() => { @@ -358,16 +367,19 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const measuring = event.shiftKey || params.measureMode(); if (measuring && params.measure.preview(px, py)) params.draw(); // 맴돌 때 이길 쪽만 밝힘(381-3) — 누르면 집힐 것과 같은 셈 · 재는 중엔 끔. - const over = measuring ? null : pickAt(px, py); + // 마커 위면 마커 몫(409) — 밑에 깔린 교각점 · 눈금은 안 밝힘. + const onMark = !measuring && marks.over(px, py); + const over = measuring || onMark ? null : pickAt(px, py); hover(over); const overNode = over && "node" in over ? over.node : -1; // 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다. - canvas.style.cursor = - overNode >= 0 + canvas.style.cursor = onMark + ? "grab" + : overNode >= 0 ? state.apexLock[overNode] ? "not-allowed" : "grab" - : handleAt(px, py) || marks.over(px, py) + : handleAt(px, py) ? "grab" : over ? "pointer" diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 7c69b1794..21268d853 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -255,7 +255,6 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou // 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저 // 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다. drawContourLabels(context, scene, band, style); - drawStructureMarks(context, scene, line, style); // 구조물 측점 마커(399) — 라벨 위 · 교각점 아래 // 노드 · 손잡이는 라벨 뒤 맨 위(381-3) — 측점 · 기울기 · 높이 글자에 묻혀 안 집히던 자리. context.save(); context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; @@ -319,6 +318,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou context.restore(); drawPickedCurve(context, scene, style); drawPickHover(context, scene, line, style, NODE_R); + drawStructureMarks(context, scene, line, style); // 구조물 측점 마커 — 맨 위(409 · 교각점 · 손잡이 · 맴돔 위) context.restore(); // 회전 끝 } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts index ef3b5d847..f316717ab 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts @@ -8,13 +8,19 @@ * 고른 마커 = 알약 벌룬과 같은 창(`balloonElement` · `balloonLines`) + 그 측점 좌우 20 m 법선(391). * · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 · * 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본). - * · 집기 차례 = 교각점 · 곡선 손잡이 다음(381-3) — `_Pointer` 가 부름. + * · 그림 · 집기 차례 = 맨 위 · 맨 앞(409 · 재기 다음) — `_Render` · `_Pointer` 가 부름. + * · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로). * 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`. * ========================================================================== */ import { pickPx } from "../A00_Common/touch_bridge"; import { draftProjectId } from "../A00_Common/design/design_store"; -import { PIPE_MATCH_M, readSelection } from "../A00_Common/design/design_selection"; +import { + NO_SELECTION, + PIPE_MATCH_M, + readSelection, + selectDesign, +} from "../A00_Common/design/design_selection"; import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { structureAnchorM, @@ -32,6 +38,7 @@ import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; import { drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover"; import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input"; import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer"; +import { PLAN_PICK_FROM } from "./B05_Profile_UI_RouteEdit_Cross"; import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render"; import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; @@ -179,7 +186,7 @@ function paintMark( context.restore(); } -/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 라벨 뒤 · 교각점 아래에 부름. */ +/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 맨 마지막에 부름(409). */ export function drawStructureMarks( context: CanvasRenderingContext2D, scene: RouteEditScene, @@ -250,7 +257,7 @@ function showBalloon( } export interface StructureMarkInput { - /** 마커를 눌렀으면 고르고 끌기 시작 · true(교각점 · 손잡이 다음 차례). */ + /** 마커를 눌렀으면 고르고 끌기 시작 · true(재기 다음 맨 앞 · 409). */ press: (px: number, py: number) => boolean; /** 끄는 중이면 자리 바꿈 · true. */ move: (px: number, py: number) => boolean; @@ -258,6 +265,8 @@ export interface StructureMarkInput { release: () => boolean; /** 마커 위인가 — 커서 몫. */ over: (px: number, py: number) => boolean; + /** 고른 마커가 있으면 풂 · true(409 — 마커 아닌 곳 누름 · Esc). */ + deselect: () => boolean; } /** 끌기 · 방향키 배선 — `_Pointer` 가 부름(같은 재료 `params`). */ @@ -321,6 +330,14 @@ export function bindStructureMarks( return moving; }; + /** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판은 제 이름(`PLAN_PICK_FROM`)이라 안 비움. */ + const deselect = (): boolean => { + if (!planMarks(canvas)?.picked) return false; + selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM }); + params.draw(); + return true; + }; + // 방향키 — 마커를 골랐고 교각점을 안 골랐을 때 · 누름 ~ 뗌 한 번이 한 옮김(교각점 방향키와 같은 반복) let holdKey: ArrowKey | null = null; let timer = 0; @@ -351,9 +368,12 @@ export function bindStructureMarks( }; function onKeyDown(event: KeyboardEvent): void { if (!canvas.isConnected) return detach(); - if (!isArrowKey(event.key) || !lastPointerWithin(".b05-routeedit")) return; + const escape = event.key === "Escape"; + if ((!escape && !isArrowKey(event.key)) || !lastPointerWithin(".b05-routeedit")) return; const target = event.target as HTMLElement | null; if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return; + // Esc = 마커 고름만 풂(공용 Esc 풀기는 오른쪽 판까지 비우므로 앞에서 받음) + if (escape) return void (!activeMoving(canvas) && deselect() && event.preventDefault()); if (event.repeat || holdKey || dragging() || params.measureMode()) return; if (params.state().picked >= 0 || activeMoving(canvas)) return; // 교각점 방향키 몫 const found = planMarks(canvas); @@ -401,5 +421,6 @@ export function bindStructureMarks( }, release: () => !holdKey && finish(), over: (px, py) => markAt(px, py) !== null, + deselect, }; } diff --git a/resources/tester/helper_399_plan_structure_marks.cjs b/resources/tester/helper_399_plan_structure_marks.cjs index 2a25efea6..12a2a3e16 100644 --- a/resources/tester/helper_399_plan_structure_marks.cjs +++ b/resources/tester/helper_399_plan_structure_marks.cjs @@ -36,10 +36,19 @@ global.window = { clearTimeout: () => {}, clearInterval: () => {}, }; +let prevented = 0; const key = (type, name) => (listeners[type] || []).forEach((fn) => - fn({ key: name, repeat: false, target: null, preventDefault() {}, stopPropagation() {} }), + fn({ + key: name, + repeat: false, + target: null, + preventDefault: () => (prevented += 1), + stopPropagation() {}, + }), ); +/** 공용 고름에 적은 것(409 풀기) — [값, 낸 쪽]. */ +const written = []; const STUBS = { "../A00_Common/touch_bridge": { @@ -50,7 +59,16 @@ const STUBS = { "../A00_Common/input_prefs": { isTrackpadPinch: () => false }, "../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 16 }, "../A00_Common/design/design_store": { draftProjectId: () => "p1" }, - "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51, readSelection: () => selection }, + "../A00_Common/design/design_selection": { + PIPE_MATCH_M: 0.51, + NO_SELECTION: { chainageM: null, structureId: null }, + readSelection: () => selection, + selectDesign: (projectId, value, options) => { + written.push([value.chainageM, options.from]); + selection = value.chainageM === null ? null : value; + }, + }, + "./B05_Profile_UI_RouteEdit_Cross": { PLAN_PICK_FROM: "b05-plan" }, "../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true }, "./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 }, "./B05_Profile_Api_Structures": { @@ -315,7 +333,24 @@ const was = redrawn; marks.planMarksChanged("p1"); out.redrawnOnChange = redrawn - was; -// ⑥ 순수 셈 +// ⑥ 고름 풀기(409) — 고른 것 없으면 아무 일 없음 · 있으면 공용 고름을 b05-plan 으로 비움 · Esc 도 +selection = null; +out.deselectNone = [input.deselect(), written.length]; +selection = { chainageM: 40, structureId: "A" }; +out.deselectPicked = [input.deselect(), ...written.slice(-1), selection]; +selection = { chainageM: 80.17, structureId: null }; // 관 고름(누가거리만) +prevented = 0; +key("keydown", "Escape"); +out.escapePicked = [...written.slice(-1), selection, prevented]; +const writes = written.length; +key("keydown", "Escape"); // 고른 것 없음 = 공용 Esc 몫(안 막음) +out.escapeNone = [written.length - writes, prevented]; +// 마커 누름 = 고름(공용 고름 길) · 법선 · 창 — 그린 차례는 `_Render` 맨 끝(시험이 원본으로 봄) +input.press(80, 0); +input.release(); +out.pressAfterRelease = selects[selects.length - 1]; + +// ⑦ 순수 셈 out.logic = { round: [logic.markChainage(41.4, 200), logic.markChainage(41.6, 200)], clamp: [logic.markChainage(-3, 200), logic.markChainage(250, 199.6)], diff --git a/resources/tester/helper_411_a_cross_structure.cjs b/resources/tester/helper_411_a_cross_structure.cjs new file mode 100644 index 000000000..aadd50bb2 --- /dev/null +++ b/resources/tester/helper_411_a_cross_structure.cjs @@ -0,0 +1,159 @@ +/* 411-A B05 평면 오른쪽 횡단 판 — 구조물 · 관을 골라도 그 측점 횡단 · 옮긴 뒤에도 열쇠로 따라감. + * `_RouteEdit_Cross` 를 Node 로 돌려 JSON 한 줄(`test_411_a_cross_structure.py` 가 판정). 묶음 · 그림 · 가게는 가짜 · + * 고름 구독 · 측점 짚기 · 판 채우기는 진짜. + * node helper_411_a_cross_structure.cjs + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); +const out = {}; + +/* 가짜 DOM — 판(제목 · 상자 · 발) 글만 봄 */ +function fakeElement() { + const parts = new Map(); + const element = { + textContent: "", + children: [], + isConnected: true, + clientHeight: 150, + setAttribute() {}, + append: (...items) => element.children.push(...items), + replaceChildren: (...items) => (element.children = items), + getBoundingClientRect: () => ({ width: 240, height: 150 }), + querySelector: (selector) => { + if (!parts.has(selector)) parts.set(selector, fakeElement()); + return parts.get(selector); + }, + }; + return element; +} +global.document = { createElement: fakeElement }; +global.ResizeObserver = class { + observe() {} + disconnect() {} +}; + +let listener = null; +let stations = []; +const route = { id: 1 }; +const subscribers = []; +const pipe = { + output: (stage) => (stage === "ground" ? { stations } : {}), + input: () => route, + subscribe: (fn) => (subscribers.push(fn), () => {}), +}; +const crossStage = () => subscribers.forEach((fn) => fn({ kind: "stage", stage: "cross" })); + +const STUBS = { + "../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan() {} }, + "../A00_Common/design/design_pipeline_page": { pagePipeline: () => pipe }, + "../A00_Common/design/design_selection": { + nearestWithin: (items, at, of) => { + let best; + let gap = 0.51; + for (const item of items) + if (Math.abs(of(item) - at) < gap) [best, gap] = [item, Math.abs(of(item) - at)]; + return best; + }, + selectDesign() {}, + subscribeSelection: (_projectId, fn) => ((listener = fn), () => {}), + }, + "../A00_Common/design/design_store": { + useDesignStore: () => ({ subscribe: () => () => {}, get: () => null }), + }, + "./logic/B05_Profile_Pipeline_Detail": { + stationSection: (station) => ({ chainage_m: station.chainageM, samples: [] }), + }, + "./B05_Profile_UI_RouteEdit_Cross_Draw": { + buildCrossSvg: () => ({ svg: fakeElement(), layer: null, width: 240, height: 150 }), + CROSS_PLOT_PAD: 0, + summarizeCross: (preview) => `횡단 ${preview.chainage_m}`, + }, + "../A00_Common/b_page_state": { readStateRaw: () => null }, + "../A00_Common/design/design_page_route": { pageRouteId: () => null }, + "../common_util/common_util_station": { stationLabel: (chainageM) => String(chainageM) }, + "./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new Map() }, + "./B05_Profile_UI_RouteEdit_Cross_Card": { + routeCrossCard: () => fakeElement(), + routeCrossSource: () => null, + }, +}; +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 (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const cross = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Cross.ts")); +const station = (key, chainageM, kind, id = null) => ({ key, chainageM, kind, id }); +const grid = [ + station("end:BP", 0, "end"), + station("grid:1", 20, "grid"), + station("grid:2", 40, "grid"), +]; +const side = fakeElement(); +const preview = cross.createCrossPreview({ + projectId: "p1", + side, + request: () => ({ vertices: [], min_radius_m: 15, station_interval_m: 20 }), +}); +const [current] = side.children; +const shownNow = () => ({ + station: current.querySelector(".b05-routeedit__cross-station").textContent, + foot: current.querySelector(".b05-routeedit__cross-foot").textContent, +}); +const pick = (chainageM, structureId, from) => listener({ chainageM, structureId }, from); + +// ① 구조물 고름(id) = 그 측점 횡단 · 법선은 마커 몫(structure) +stations = [ + ...grid, + station("structure:A", 45, "structure", "A"), + station("pipe:p1", 80.17, "pipe", "p1"), +]; +pick(45, "A", "b05"); +out.structurePick = { ...shownNow(), structure: preview.watched()?.structure ?? null }; +// ② 옮김 — 묶음이 새 자리로(같은 열쇠) · cross 차례 = 새 자리 횡단(고름은 옛 45 그대로) +stations = [ + ...grid, + station("structure:A", 52, "structure", "A"), + station("pipe:p1", 80.17, "pipe", "p1"), +]; +crossStage(); +out.structureMoved = { ...shownNow(), at: preview.watched()?.chainageM ?? null }; +// ③ 관 고름(누가거리만) = 그 측점 횡단 +pick(80.17, null, "b05"); +out.pipePick = { ...shownNow(), structure: preview.watched()?.structure ?? null }; +// ④ 평면 마커 고름 풂(b05-plan) = 판 그대로 +pick(null, null, "b05-plan"); +out.planRelease = shownNow(); +// ⑤ 일반측점 고름 = 그 측점 횡단 · 법선(structure 없음) +pick(20, null, "b05"); +out.gridPick = { ...shownNow(), structure: preview.watched()?.structure ?? null }; +// ⑥ 묶음 전 구조물 고름 — 열쇠 없이 기다렸다 묶음 뒤 열쇠로 붙듦 · 그 뒤 옮김도 따라감 +stations = []; +pick(60, "B", "b05"); +out.beforePipeline = shownNow(); +stations = [...grid, station("structure:B", 60, "structure", "B")]; +crossStage(); +out.afterPipeline = shownNow(); +stations = [...grid, station("structure:B", 66, "structure", "B")]; +crossStage(); +out.latchedMoved = shownNow(); +// ⑦ 다른 보기가 풂(null · b05) = 판 비움(382-2 그대로) +pick(null, null, "b05"); +out.otherRelease = { ...shownNow(), watched: preview.watched() }; +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_399_plan_structure_marks.py b/resources/tester/test_399_plan_structure_marks.py index b1129ff5e..04acdd853 100644 --- a/resources/tester/test_399_plan_structure_marks.py +++ b/resources/tester/test_399_plan_structure_marks.py @@ -65,18 +65,23 @@ def test_공용_고름과_같은_길() -> None: assert " onStructureSelect,\n onStructureMarkMove,\n" in view # 알약 콜백 = 같은 함수 assert "select: onStructureSelect," in view and "move: onStructureMarkMove," in view render = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Render.ts") - labels = render.index("drawContourLabels(context, scene, band, style);\n drawStructureMarks") + labels = render.index("drawContourLabels(context, scene, band, style);") nodes = render.index("scene.planned.forEach((vertex, index) => {") - assert labels < nodes # 라벨 위 · 교각점 아래 + hover = render.index("drawPickHover(context, scene, line, style, NODE_R);") + marks = render.index("drawStructureMarks(context, scene, line, style);") + turned = render.index("context.restore(); // 회전 끝") + assert labels < nodes < hover < marks < turned # 맨 위(409 — 교각점 · 손잡이 · 맴돔 위) -def test_집기_차례는_교각점_손잡이_다음() -> None: +def test_집기_차례는_재기_다음_맨_앞() -> None: pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") + measure = pointer.index("void params.measure.pick(px, py);") + mark = pointer.index("if (marks.press(px, py)) {") + release = pointer.index("marks.deselect();") node = pointer.index("if (hitNode >= 0) {") handle = pointer.index("} else if (dragHandle) {") - mark = pointer.index("} else if (marks.press(px, py)) {") tick = pointer.index('if (!pick || !("chainage" in pick)) return false;') - assert node < handle < mark < tick + assert measure < mark < release < node < handle < tick # 409 def test_끌기_1m_정수_놓을_때_한_번(got) -> None: diff --git a/resources/tester/test_409_mark_top_deselect.py b/resources/tester/test_409_mark_top_deselect.py new file mode 100644 index 000000000..4eb802637 --- /dev/null +++ b/resources/tester/test_409_mark_top_deselect.py @@ -0,0 +1,76 @@ +"""409 B05 평면 구조물 마커 맨 위 · 다른 곳 누르면 고름 풀림(요구 252-4). + +그림 = 맨 마지막(교각점 · 손잡이 · 맴돔 위) · 누름 · 맴돔 · 커서 = 재기 다음 마커 먼저 · +마커 누르면 고른 교각점 풂 · +마커 아닌 곳 누름 · Esc = 공용 고름을 `b05-plan` 으로 비움(오른쪽 횡단 판 그대로). +TS 셈은 `helper_399_plan_structure_marks.cjs`(⑥ 고름 풀기). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_409_mark_top_deselect.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_399_plan_structure_marks.cjs") + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_풀기_고른_것_없으면_아무_일_없음(got) -> None: + assert got["deselectNone"] == [False, 0] + + +def test_풀기_공용_고름을_b05_plan_으로_비움(got) -> None: + assert got["deselectPicked"] == [True, [None, "b05-plan"], None] + + +def test_Esc_마커_고름만_풂(got) -> None: + assert got["escapePicked"] == [[None, "b05-plan"], None, 1] # 관 고름도 · 공용 Esc 막음 + assert got["escapeNone"] == [0, 1] # 고른 것 없음 = 공용 Esc 몫 + + +def test_마커_누름은_그대로_고름(got) -> None: + assert got["pressAfterRelease"] == "A" + + +def test_누름_마커_먼저_교각점_풂() -> None: + pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") + down = pointer[pointer.index("if (marks.press(px, py)) {") :] + branch = down[: down.index("marks.deselect();")] + assert "params.setPicked(-1);" in branch and "params.syncCurveBar();" in branch + assert "canvas.setPointerCapture(event.pointerId);\n return;" in branch + assert "else if (marks.press(" not in pointer # 옛 차례(교각점 · 손잡이 다음) 걷힘 + + +def test_맴돔_커서_마커_먼저() -> None: + pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") + over = pointer.index("const onMark = !measuring && marks.over(px, py);") + pick = pointer.index("const over = measuring || onMark ? null : pickAt(px, py);") + cursor = pointer.index("canvas.style.cursor = onMark") + assert over < pick < cursor + + +def test_오른쪽_판은_제_이름_알림을_거름() -> None: + cross = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts") + assert "if (from !== PLAN_PICK_FROM) follow(selection);" in cross + marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") + assert "selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });" in marks diff --git a/resources/tester/test_411_a_cross_structure.py b/resources/tester/test_411_a_cross_structure.py new file mode 100644 index 000000000..f8abb0637 --- /dev/null +++ b/resources/tester/test_411_a_cross_structure.py @@ -0,0 +1,58 @@ +"""411-A B05 평면 오른쪽 횡단 판 — 구조물 · 관을 골라도 그 측점 횡단(요구 252-6) · +옮긴 뒤에도 그 측점. + +TS 셈은 `helper_411_a_cross_structure.cjs`(Node · `_RouteEdit_Cross` 진짜 · 묶음 · 그림 가짜). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_411_a_cross_structure.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HELPER = Path(__file__).with_name("helper_411_a_cross_structure.cjs") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_구조물_고름_그_측점_횡단(got) -> None: + assert got["structurePick"] == {"station": "45", "foot": "횡단 45", "structure": True} + + +def test_옮긴_뒤_열쇠로_새_자리(got) -> None: + assert got["structureMoved"] == {"station": "52", "foot": "횡단 52", "at": 52} + + +def test_관_고름_그_측점_횡단(got) -> None: + assert got["pipePick"] == {"station": "80.17", "foot": "횡단 80.17", "structure": True} + + +def test_평면_마커_풂은_판_그대로(got) -> None: + assert got["planRelease"] == {"station": "80.17", "foot": "횡단 80.17"} + + +def test_일반측점_그대로(got) -> None: + assert got["gridPick"] == {"station": "20", "foot": "횡단 20", "structure": None} + + +def test_묶음_전_고름도_열쇠로_붙듦(got) -> None: + assert got["beforePipeline"]["foot"] == "계산 중…" + assert got["afterPipeline"] == {"station": "60", "foot": "횡단 60"} + assert got["latchedMoved"] == {"station": "66", "foot": "횡단 66"} + + +def test_다른_보기_풂은_판_비움(got) -> None: + assert got["otherRelease"]["station"] == "" and got["otherRelease"]["watched"] is None