From 2abf70eeac5fe33b7425a22f01b1328e69f4eb52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 13:12:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?test(b06):=20=EB=82=A8=EC=9D=80=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=EB=B9=A8=EA=B0=95=20=EB=91=98=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20=E2=80=94=2091=20=EC=86=8C=EB=8B=A8=20=EA=B8=B0=EB=8C=80?= =?UTF-8?q?=EA=B0=92=20178-2=20=EA=B7=9C=EC=B9=99=20=C2=B7=20B07=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EB=B2=94=EC=9C=84=20=EB=B2=88=EB=93=A4=20?= =?UTF-8?q?document=20=EA=B0=80=EB=93=9C(186-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_91: 구조물 끝 절토선엔 소단 없음(소단 = 성토 사면 한 단 · 178-2) - Cross_Panel_Base: document 없을 때 pointerdown 듣개 안 붙임 — Node 번들(B07 횡단 범위) 실행 살림 · 화면 동작 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B06_Section/B06_Section_UI_Cross_Panel_Base.ts | 16 +++++++++------- resources/tester/test_91_ground_free.py | 12 ++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts index f8b258525..08da4506a 100644 --- a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts +++ b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts @@ -75,13 +75,15 @@ export function buildPanelShell(onClose: () => void): { /** 마지막으로 누른 자리 — 방향키는 **초점 있는 쪽만** 받는다(계획서 24-3b). 횡단 보기에서 조정창과 * 종단도(▲▼ 계획고)가 한 화면에 함께 있어, 종단도를 누른 뒤에는 조정창이 방향키를 넘긴다. */ let lastPointer: Element | null = null; -document.addEventListener( - "pointerdown", - (event) => { - lastPointer = event.target instanceof Element ? event.target : null; - }, - true, -); +// 서버 Node 번들(B07 횡단 범위)도 이 파일을 실음 — document 없으면 듣개 안 붙임(186-2) +if (typeof document !== "undefined") + document.addEventListener( + "pointerdown", + (event) => { + lastPointer = event.target instanceof Element ? event.target : null; + }, + true, + ); /** 방향키가 종단도 몫인가 — 마지막으로 누른 자리가 종단도 안이면 참. */ export function arrowsBelongToProfile(): boolean { diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index 3ae13bcf1..70f2fda8a 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -117,9 +117,9 @@ def test_절토_쪽_배관_유출_벽_뒤_절토사면_살아남(fit: dict) -> N assert _knee(cut) and cut["endGap"] < 0.01 -def test_소단은_구조물_끝에서_새로_셈(fit: dict) -> None: - legs = fit["cutOutletBerm"]["legs"] - flat = next(i for i, leg in enumerate(legs) if leg["ratio"] is None) - assert sum(leg["slant"] for leg in legs[:flat]) == pytest.approx(3.0, abs=1e-3) - assert legs[flat]["run"] == pytest.approx(0.5, abs=1e-3) - assert fit["cutOutletBerm"]["endGap"] < 0.01 +def test_구조물_끝_절토엔_소단_없음(fit: dict) -> None: + """소단 = 성토 사면 한 단(178-2) — 측점에 소단이 있어도 구조물 끝 절토선은 그대로.""" + berm = fit["cutOutletBerm"] + assert all(leg["ratio"] is not None for leg in berm["legs"]) + assert berm["legs"] == fit["cutOutlet"]["legs"] + assert berm["endGap"] < 0.01 From 9e69bec512f032c2a33cc033d0149d171ffbcb7a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 13:21:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(B06=C2=B7B05):=20=EB=85=B8=EB=A9=B4?= =?UTF-8?q?=EA=B3=B5=20=EA=B3=A0=EB=A6=84=20=EC=84=B8=20=EA=B0=88=EB=9E=98?= =?UTF-8?q?=20=C2=B7=20=ED=98=95=EC=83=81=20=EB=88=84=EB=A6=84=20=3D=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B3=A0=EB=A6=84=20=C2=B7=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=ED=95=98=EB=82=98=20=3D=20=EA=B7=B8=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90(189-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ② 카드 안 노면공 포장층 누름 = 목록 고름과 같은 길(범위 카드 전부 · 포장층 강조) — 카드 고름보다 먼저 가로챔 - ③ 카드 하나 누름 = 연 노면공 · 범위 강조 풂 · 그 카드 하나(165-1 노면공 지킴 · 167-3 재누름 그대로 없앰) - 카드 고름 세션 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만) - 종단: 구조물 넘김이면 측점선 · 3D 띠 안 켬 - 새 시험 test_189_1_pave_pick · test_165 · test_188_1 기대값 41번 규칙으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/design/design_view_cross.ts | 8 +- A00_Common/design/design_view_profile.ts | 7 +- .../B06_Section_UI_Cross_Design_Surface.ts | 36 +++-- .../B06_Section_UI_Page_Structures_Panel.ts | 18 +-- B06_Section/B06_Section_UI_Section_View.ts | 28 ++-- .../B06_Section_UI_Section_View_Types.ts | 2 + resources/tester/helper_165_pavement_flow.cjs | 42 ++++-- resources/tester/helper_188_1_last_pick.cjs | 4 +- resources/tester/helper_189_1_pave_shape.cjs | 39 ++++++ resources/tester/test_165_pavement_flow.py | 53 ++++--- resources/tester/test_188_1_last_pick.py | 6 +- resources/tester/test_189_1_pave_pick.py | 130 ++++++++++++++++++ 12 files changed, 301 insertions(+), 72 deletions(-) create mode 100644 resources/tester/helper_189_1_pave_shape.cjs create mode 100644 resources/tester/test_189_1_pave_pick.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index e1ae1e94c..21bb3380c 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -379,6 +379,8 @@ export async function mountCrossView( ); // 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자). // 연동으로 옆에서 넘어온 카드는 벽을 찍었을 때만 **소유 측점** 시설 · 카드만 고르면 일반 측점(79-6). + // 카드 안 노면공 형상 누름 = 목록 고름과 같은 길(189-1) + sectionView.setPavementSelectListener((id) => structuresPanel.selectStructure(id)); sectionView.setStationSelectListener((stationId) => { const target = stationId ? sectionDetail?.cross_sections.find((section) => section.station_id === stationId) @@ -389,9 +391,11 @@ export async function mountCrossView( return; } const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null; + const preferred = structuresPanel.preferred() !== null; const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); - // 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다. - if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" }); + // 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1) + const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null }; + if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" }); // 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면 // 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1) const pipeAt = structuresPanel.currentChainage(); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 3d501ba50..4124b09a5 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -31,7 +31,7 @@ import { restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection"; +import { NO_SELECTION, nearestWithin, readSelection, selectDesign } from "./design_selection"; import type { DesignPageHost } from "./design_page"; import { bindDesignView, @@ -124,9 +124,10 @@ export async function mountProfileView( restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. }; /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) · - * 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1). */ + * 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1) — 구조물 고름은 선 없음(189-1). */ const selectGraphAt = (id: string | null, chainageM: number | null): void => { - const sections = currentSectionDetail?.cross_sections ?? []; + const structurePick = !!readSelection(activeProjectId)?.structureId; + const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []); const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections); if ("pipeChainageM" in target) { profilePanel.setSelectedStructure(`pipe-${target.pipeChainageM.toFixed(2)}`); diff --git a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts index 275afb5e2..dcc107c99 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts @@ -64,26 +64,38 @@ function standardEdges(edges: { left: Edge; right: Edge }, design: CrossDesign) }; } +type PaveMark = { type_id: string; start_m?: number | null; end_m?: number | null }; + +/** 포장층 범위 표식(종류 · 시작 · 끝)이 그 노면공인가 — 겹친 노면공 가름. */ +function isPaveLayerOf(data: DOMStringMap, mark: PaveMark): boolean { + const [low, high] = [mark.start_m ?? 0, mark.end_m ?? 0].sort((a, b) => a - b); + return ( + data.paveType === mark.type_id && + Math.abs(Number(data.paveStart) - low) < 0.01 && + Math.abs(Number(data.paveEnd) - high) < 0.01 + ); +} + /** * 고른 노면공의 포장층만 `is-active`(152-5) — 범위 표식(종류 · 시작 · 끝)이 맞는 층. 겹친 노면공은 하나만. * `mark` 없음 = 모두 풂. */ -export function highlightPavement( - root: ParentNode, - mark?: { type_id: string; start_m?: number | null; end_m?: number | null }, -): void { - const [low, high] = [mark?.start_m ?? 0, mark?.end_m ?? 0].sort((a, b) => a - b); +export function highlightPavement(root: ParentNode, mark?: PaveMark): void { for (const layer of root.querySelectorAll(".b06-chart__pavement")) { - const { paveType, paveStart, paveEnd } = layer.dataset; - const same = - !!mark && - paveType === mark.type_id && - Math.abs(Number(paveStart) - low) < 0.01 && - Math.abs(Number(paveEnd) - high) < 0.01; - layer.classList.toggle("is-active", same); + layer.classList.toggle("is-active", !!mark && isPaveLayerOf(layer.dataset, mark)); } } +/** 누른 자리의 포장층 → 그 노면공(189-1 · 형상 누름 = 목록 고름) · 범위 표식 없는 층 · 밖 = undefined. */ +export function pavementAt( + target: EventTarget | null, + marks: ReadonlyArray, +): T | undefined { + const layer = (target as Element | null)?.closest?.(".b06-chart__pavement"); + if (!layer?.dataset.paveType) return undefined; + return marks.find((mark) => isPaveLayerOf(layer.dataset, mark)); +} + /** * 포장층 — 노면선(= 포장 윗면) 아래로 두께만큼. 노면공 범위(`section.pavement`)면 그 두께 · 무늬 · * 확폭 켬/끔(여러 건은 아래로 차례로 쌓음) · 없으면 카드 포장 켬일 때 표준 두께 채움만. diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 25c0eaded..0abc4bfc5 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -123,6 +123,8 @@ export interface B06StructuresPanel { selectionAt: (chainageM: number | null) => DesignSelection; /** 다음 카드 고름이 측점보다 먼저 열 구조물 — 알약 · 넘김값이 누른 것(null = 측점 규칙대로). */ prefer: (structureId: string | null) => void; + /** 지금 카드 고름이 먼저 열 구조물(목록 · 알약 · 넘김) — 없으면 null(카드만 고름 · 189-1). */ + preferred: () => string | null; /** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */ selectStructure: (structureId: string) => void; /** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */ @@ -164,8 +166,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc markTypes.find((type) => type.type_id === entry.type_id)?.group !== "G"; /** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */ let preferredId: string | null = null; - /** 폼에 열린 구조물 — 범위 안 빈 카드를 눌러도 연 노면공을 지킴(165-1). */ - let openId: string | null = null; /** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */ const focusOf = (structure: StructureInstance): number => { const anchor = structureAnchorM(structure); @@ -209,7 +209,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } }, onSelect: (structure) => { - openId = structure?.structure_id ?? null; if (!structure) { deps.markStructure?.(null); // 풂 = 알약도 끔(53-2) return; @@ -547,24 +546,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc prefer: (structureId) => { preferredId = structureId; }, + preferred: () => preferredId, selectStructure: (structureId) => section.selectById(structureId), showAtChainage: (chainageM) => { currentChainageM = null; const selection: DesignSelection = { chainageM, structureId: preferredId }; // 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에 // 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳). + // 카드 하나 고름 = 그 측점 기준 — 연 노면공은 풂(189-1 · 노면공은 덮어 열지 않음 152-5) const hit = resolveSelection(selection, pipeFacilities, structures, coverable); - // 범위 안 빈 카드(관 · 덮는 구조물 없음) = 연 노면공 그대로 · 카드만 옮김(165-1) - const open = structures.find((entry) => entry.structure_id === openId); - if ( - chainageM !== null && - hit.pipeChainageM === null && - hit.structureId === null && - open && - !coverable(open) && - coversChainage(open, chainageM) - ) - return { chainageM, structureId: openId }; // 폼은 위에서 푼 값으로 엶 — 공용 `selectAt` 은 노면공을 덮어 엶(152-5) if (hit.pipeChainageM !== null) { currentChainageM = hit.pipeChainageM; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index a5a2517d5..a67009b38 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -68,7 +68,7 @@ import { } from "./B06_Section_UI_Section_Common"; import type { SectionViewController } from "./B06_Section_UI_Section_View_Types"; import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice"; -import { highlightPavement } from "./B06_Section_UI_Cross_Design_Surface"; +import { highlightPavement, pavementAt } from "./B06_Section_UI_Cross_Design_Surface"; import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile"; import type { DesignPageShared } from "../A00_Common/design/design_shared"; @@ -270,6 +270,8 @@ export function createSectionView( /** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */ let stationSelectListener: ((stationId: string | null) => void) | null = null; + /** 카드 안 노면공 형상 누름 수신자 — 목록 고름과 같은 길(189-1). */ + let pavementSelectListener: ((structureId: string) => void) | null = null; /** * 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**. @@ -288,16 +290,6 @@ export function createSectionView( /** 같은 측점을 다시 고르면 선택을 푼다(2026-08-02 사용자 지시) — 카드·측점선 어느 쪽이든. */ const selectStation = (stationId: string, scroll: boolean): void => { - // 고른 노면공 범위 안 카드는 다시 눌러도 그대로 — 풂은 빈 자리 · 목록 · 알약(167-3) - const section = currentDetail?.cross_sections.find((entry) => entry.station_id === stationId); - const held = - section && isPavement(spanMarkId) - ? inSpan( - section, - spanMarks.find((entry) => entry.structure_id === spanMarkId), - ) - : false; - if (selectedStationId === stationId && held) return; const previous = selectedStationId; const deselect = selectedStationId === stationId; selectedStationId = deselect ? null : stationId; @@ -429,6 +421,17 @@ export function createSectionView( grid.addEventListener("click", (event) => { if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection(); }); + // 노면공 형상 누름 = 목록 고름(범위 카드 전부 · 형상 강조) — 카드 고름보다 먼저 가로챔(189-1) + grid.addEventListener( + "click", + (event) => { + const pave = pavementAt(event.target, spanMarks); + if (!pave?.structure_id || !isPavement(pave.structure_id)) return; + event.stopPropagation(); + pavementSelectListener?.(pave.structure_id); + }, + true, + ); // 열 수 = 사용자 값 · 폭 = (칸 폭 − 간격) ÷ 열 수(`_Section_Columns` · 138-3). cachedCardWidth = crossCardWidth(renderWidth, columnCount, CROSS_GRID_GAP); // CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제 @@ -609,6 +612,9 @@ export function createSectionView( setStationSelectListener(listener) { stationSelectListener = listener; }, + setPavementSelectListener(listener) { + pavementSelectListener = listener; + }, setStructureEdit(edit) { structureEdit = edit; }, diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 07a991790..939e33f2b 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -56,6 +56,8 @@ export interface SectionViewController { /** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을 * 올린다(2026-08-29 일원화). null = 선택 해제. */ setStationSelectListener: (listener: (stationId: string | null) => void) => void; + /** 카드 안 노면공 형상 누름(189-1) — 목록 고름과 같은 길로 보냄. */ + setPavementSelectListener: (listener: (structureId: string) => void) => void; clear: () => void; /** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자 * 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */ diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 75a3f17e1..209ea2d1f 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -163,11 +163,16 @@ let marks = []; const profile = { markedId: null, pill: null, + line: null, // 종단 측점선(design_profile selectedStationId) selectMark(id) { this.markedId = id; this.pill = id; + // setSelectedStructure(id, keepStation) — 선 있는 구조물(관)만 그 선 · 아니면 카드 선 지킴(177-3) + const pipe = PIPES.find((entry) => `pipe-${entry.chainage_m.toFixed(2)}` === id); + if (pipe) this.line = cardAt(pipe.chainage_m).station_id; }, showCard(card) { + this.line = card ? card.station_id : null; if (!card) return; const pipe = PIPES.find((entry) => Math.abs(entry.chainage_m - card.chainage_m) < 0.51); // 관 측점 = 그 측점선 짝 알약 · 일반 측점 = 꺼짐 — 고른 구조물이 덮으면 지킴(53-1) @@ -189,14 +194,7 @@ const view = { return !!mark && markTypes.find((type) => type.type_id === mark.type_id)?.group === "G"; }, selectStation(id) { - // 고른 노면공 범위 안 카드 재누름 = 그대로(167-3 · Section_View `selectStation`) - const card = CARDS.find((entry) => entry.station_id === id); - const mark = marks.find((entry) => entry.structure_id === this.spanMarkId); - const held = - this.isPavement(this.spanMarkId) && - card.chainage_m >= Math.min(mark.start_m, mark.end_m) - 0.01 && - card.chainage_m <= Math.max(mark.start_m, mark.end_m) + 0.01; - if (this.selected === id && held) return; + // 카드 하나 고름 = 그 측점 · 같은 카드 재누름 = 풂(189-1 · Section_View `selectStation`) const previous = this.selected; this.selected = this.selected === id ? null : id; profile.showCard(CARDS.find((card) => card.station_id === this.selected) ?? null); @@ -243,8 +241,11 @@ view.listener = (stationId) => { return; } const owner = wallSelecting ? ownerOf(target) : null; + const preferred = panel.preferred() !== null; const opened = panel.showAtChainage(owner?.chainage_m ?? target.chainage_m); - if (!wallSelecting) selection.selectDesign(PID, opened, { from: "b06" }); + // 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1) + const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null }; + if (!wallSelecting) selection.selectDesign(PID, picked, { from: "b06" }); const pipeAt = panel.currentChainage(); const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); view.selectMark(mark, false); @@ -260,6 +261,7 @@ async function build(list) { view.spanMarkId = null; profile.markedId = null; profile.pill = null; + profile.line = null; panel = panelModule.createB06StructuresPanel({ projectId: PID, shared: { view: () => "cross" }, @@ -292,6 +294,7 @@ function snap() { paveCards: pave ? CARDS.filter((entry) => inSpan(entry.chainage_m)).length : 0, card: card ? card.chainage_m : null, sid: selection.readSelection(PID)?.structureId ?? null, + line: profile.line, }; } @@ -302,6 +305,8 @@ const pillPick = (id) => { panel.selectStructure(id); }; const cardPick = (at) => view.selectStation(cardAt(at).station_id); +/** 카드 안 노면공 형상 누름 — Section_View 가 카드 고름 전에 가로채 design_view_cross 가 목록 길로(189-1). */ +const shapePick = (id) => panel.selectStructure(id); /** 벽 누름 — SPS `revetOffset.select` 감싸개(소유 측점 폼) 뒤 카드 고름(벽 고르는 턴). */ const wallPick = (cardAtM, ownerM) => { wallOwner = { card: cardAtM, chainage_m: ownerM }; @@ -340,6 +345,25 @@ const wallPick = (cardAtM, ownerM) => { cardPick(280); // 범위 밖 카드 재누름 = 카드 풂(옛 규칙 그대로) out.outsideRe = snap(); + // 189-1 세 갈래 — ① 목록 · ② 형상 · ③ 카드 하나 · 종단 측점선 · 세션 + await build([PAVE, { structure_id: "sign", type_id: "hazard_sign", chainage_m: 127.86 }]); + listPick("pave"); + out.r1 = snap(); + cardPick(140); // ③ 범위 안 카드 = 목록 고름 · 범위 강조 풂 · 그 카드 하나 + out.r3 = snap(); + shapePick("pave"); // ② 고른 카드 안 형상 = ① 과 같음(옛 카드 풂) + out.r2 = snap(); + out.r2reveal = view.revealed; + cardPick(100); // ③ 다시 + out.r3b = snap(); + cardPick(100); // 같은 카드 재누름 = 풂(옛 167-3 「그대로」 없앰) + out.r3re = snap(); + await build([PAVE]); + shapePick("pave"); // ② 처음부터 형상 누름 + out.r2first = snap(); + cardPick(80); // ③ 관 자리 카드 = 관 + out.r3pipe = snap(); + // 5. B05 알약 → B06 복귀 — 넘김값 {범위 첫 카드 · sid} · `restoreStructurePick` 의 focusStation 차례 await build([PAVE]); selection.selectDesign(PID, { chainageM: 40, structureId: "pave" }, { from: "b05" }); diff --git a/resources/tester/helper_188_1_last_pick.cjs b/resources/tester/helper_188_1_last_pick.cjs index ba4d3c450..b3e1aa938 100644 --- a/resources/tester/helper_188_1_last_pick.cjs +++ b/resources/tester/helper_188_1_last_pick.cjs @@ -88,7 +88,9 @@ const sb = sync.createSelectionSync({ sb.syncIrregularSelection(id); }, selectGraph: (id, c) => { - const target = sync.graphTargetAt(id, c ?? null, pipes, cards); + // 구조물 고름은 측점선 없음(189-1 · view 의 selectGraphAt 과 같음) + const sections = sel.readSelection(P)?.structureId ? [] : cards; + const target = sync.graphTargetAt(id, c ?? null, pipes, sections); if ("pipeChainageM" in target) graph.mark = `pipe-${target.pipeChainageM.toFixed(2)}`; else { graph.station = target.stationId; diff --git a/resources/tester/helper_189_1_pave_shape.cjs b/resources/tester/helper_189_1_pave_shape.cjs new file mode 100644 index 000000000..b3a7b75d0 --- /dev/null +++ b/resources/tester/helper_189_1_pave_shape.cjs @@ -0,0 +1,39 @@ +/* PLAN 189-1 단위검증 헬퍼 — 카드 안 누른 포장층 → 그 노면공(`pavementAt`) · 범위 표식으로 겹친 노면공 가름. + * 화면 TS(`B06_Section_UI_Cross_Design_Surface.ts`)를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON. + * 짝 = `test_189_1_pave_pick.py`(세 갈래 상태는 `helper_165_pavement_flow.cjs` 가 냄). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Surface.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const { pavementAt } = box.exports; + +const marks = [ + { structure_id: "pave", type_id: "pavement_concrete", start_m: 30, end_m: 250 }, + { structure_id: "pave2", type_id: "pavement_concrete", start_m: 100, end_m: 200 }, + { structure_id: "wall", type_id: "retaining_wall", start_m: 100, end_m: 200 }, +]; +/** 누른 요소 흉내 — `closest(".b06-chart__pavement")` 가 그 층(없으면 null). */ +const hit = (dataset) => ({ closest: () => (dataset ? { dataset } : null) }); +const layer = (type, start, end) => ({ + paveType: type, + paveStart: String(start), + paveEnd: String(end), +}); + +const out = { + pave: pavementAt(hit(layer("pavement_concrete", 30, 250)), marks)?.structure_id ?? null, + pave2: pavementAt(hit(layer("pavement_concrete", 100, 200)), marks)?.structure_id ?? null, + plainLayer: pavementAt(hit({}), marks)?.structure_id ?? null, // 범위 표식 없는 카드 포장 + outside: pavementAt(hit(null), marks)?.structure_id ?? null, + noTarget: pavementAt(null, marks)?.structure_id ?? null, + unknown: pavementAt(hit(layer("pavement_concrete", 0, 10)), marks)?.structure_id ?? null, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_165_pavement_flow.py b/resources/tester/test_165_pavement_flow.py index e03dfe7c6..1afa758f1 100644 --- a/resources/tester/test_165_pavement_flow.py +++ b/resources/tester/test_165_pavement_flow.py @@ -3,11 +3,11 @@ 못박는 것(단계마다 폼 · 알약 · 포장층 · 고른 카드 · 세션 sid 가 같은 것을 가리킴) 1. 목록 노면공 = 포장층 · 알약 · 폼 · sid 모두 노면공 · 카드는 안 고름(스크롤만 · 177-4 · 기준점이 범위 경계여도 범위 밖 카드로 안 감). - 2. 범위 안 빈 카드(관 · 덮는 구조물 없음) = 노면공 그대로 · 카드만 옮김(165-1). + 2. 범위 안 빈 카드 = 카드 하나 고름 · 노면공 풂 · 세션 = 그 측점(189-1 이 165-1 · 167-3 을 바꿈). 3. 고른 카드의 벽 = 관 폼 + 관 알약(알약이 꺼진 채 폼만 남지 않음 · 165-2) · 포장층 풂. 4. 알약 다시 = 1 과 같음. 관 자리 카드 = 관 · 범위 밖 카드 = 모두 풂(지금처럼). 5. B05 알약 → B06 복귀 = 1 과 같음 · 넘김 카드에서 안 튐. - 6. 겹친 노면공 둘 = 고른 층만 · 범위 안 카드 눌러도 그 층 그대로. + 6. 겹친 노면공 둘 = 고른 층만 · 범위 안 카드 누름 = 카드 하나(189-1). Node 헬퍼 `helper_165_pavement_flow.cjs` 가 진짜 SPN 에 폼 · 카드 · 알약 흉내를 붙여 돌림. 화면은 사용자 확인. """ @@ -41,6 +41,7 @@ def out(): def _pave(card, layer="pave", cards=12): + # 노면공 고름 = 카드 하나 안 고름 · 종단 측점선 없음(189-1) return { "form": layer, "pill": layer, @@ -48,10 +49,31 @@ def _pave(card, layer="pave", cards=12): "paveCards": cards, "card": card, "sid": layer, + "line": None, } -PIPE = {"form": "pipe-80.17", "pill": "pipe-80.17", "paveLayer": None, "paveCards": 0} +def _card(at): + # 카드 하나 고름 = 그 측점 기준 · 노면공 · 범위 강조 풂 · 세션 sid 없음(189-1) + line = None if at is None else f"s{at}" + return { + "form": None, + "pill": None, + "paveLayer": None, + "paveCards": 0, + "card": at, + "sid": None, + "line": line, + } + + +PIPE = { + "form": "pipe-80.17", + "pill": "pipe-80.17", + "paveLayer": None, + "paveCards": 0, + "line": "s80.17", +} def test_1_목록_노면공(out): @@ -59,11 +81,11 @@ def test_1_목록_노면공(out): assert out["reveal1"] == "s40" # 기준점 30 = 범위 경계 → 범위 안 첫 카드(20 아님) -def test_2_범위_안_빈_카드는_노면공_그대로(out): - assert out["step2first"] == _pave(40) - assert out["step2re"] == _pave(40) # 이미 고른 카드 재누름도 그대로(167-3) - assert out["step2"] == _pave(140) - assert out["step2b"] == _pave(100) +def test_2_범위_안_빈_카드는_카드_하나(out): + assert out["step2first"] == _card(40) + assert out["step2re"] == _card(None) # 같은 카드 재누름 = 풂(189-1) + assert out["step2"] == _card(140) + assert out["step2b"] == _card(100) def test_3_벽은_관_폼과_관_알약_함께(out): @@ -73,14 +95,7 @@ def test_3_벽은_관_폼과_관_알약_함께(out): def test_4_알약_다시_관_카드_범위_밖(out): assert out["step4"] == _pave(None) assert out["pipeCard"] == {**PIPE, "card": 80, "sid": None} - assert out["outside"] == { - "form": None, - "pill": None, - "paveLayer": None, - "paveCards": 0, - "card": 280, - "sid": None, - } + assert out["outside"] == _card(280) assert out["outsideRe"]["card"] is None @@ -90,16 +105,16 @@ def test_5_B05_복귀(out): def test_6_겹친_노면공(out): assert out["step6"] == _pave(None, "pave2", 6) - assert out["step6b"] == _pave(160, "pave2", 6) + assert out["step6b"] == _card(160) def test_배선(): panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") assert "deps.focusChainage(focusOf(structure), pave);" in panel - assert "return { chainageM, structureId: openId };" in panel + assert "openId" not in panel # 범위 안 빈 카드 = 연 노면공 지킴(165-1) 없앰 · 189-1 # 관 알약은 폼 뒤(폼이 연 구조물을 닫으며 알약을 끔) at = panel.index("section.selectPipeByChainage(hit.pipeChainageM);") assert at < panel.index("deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);", at) view = _read("B06_Section", "B06_Section_UI_Section_View.ts") assert "spanMarkId = span || isPavement(structureId) ? structureId : null;" in view - assert "if (selectedStationId === stationId && held) return;" in view + assert "held" not in view # 범위 카드 재누름 = 그대로(167-3) 없앰 · 189-1 diff --git a/resources/tester/test_188_1_last_pick.py b/resources/tester/test_188_1_last_pick.py index 2d6e1c865..e4c80632c 100644 --- a/resources/tester/test_188_1_last_pick.py +++ b/resources/tester/test_188_1_last_pick.py @@ -74,9 +74,12 @@ def test_횡단_배관은_종단_배관_측점선_측점선_없는_관은_알약 assert out["b06BarePipe"]["graph"] == {"station": None, "mark": "pipe-50.00"} -def test_횡단_구조물은_종단에서_그_구조물을_열고_고름_한_칸을_안_덮음(out) -> None: +def test_횡단_구조물은_종단에서_그_구조물을_열고_측점선_없이_고름_한_칸을_안_덮음(out) -> None: structure = out["b06Structure"] assert structure["opened"] == ["wall"] + # 구조물 고름 = 종단 측점선 · 3D 띠 없음(189-1 · 여러 측점이라 한 측점 기준 없음) + assert structure["graph"] == {"station": None, "mark": None} + assert structure["marker"] is None # 되살리며 3D 띠 리스너가 되불러도(동기화 중) 구조물 id · 부재키가 남음 assert structure["pick"] == {"chainageM": 60, "structureId": "wall", "partKey": "own"} @@ -99,3 +102,4 @@ def test_종단_보기_배선() -> None: assert "regularChainage:" in page assert "remember: (chainageM) => rememberPick(chainageM, null)" in page assert "graphTargetAt(id, chainageM, lastPipes ?? [], sections)" in page + assert "const sections = structurePick ? [] :" in page diff --git a/resources/tester/test_189_1_pave_pick.py b/resources/tester/test_189_1_pave_pick.py new file mode 100644 index 000000000..f1948c39e --- /dev/null +++ b/resources/tester/test_189_1_pave_pick.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +"""PLAN 189-1 — 노면공(여러 측점 구간) 고름 세 갈래 · 종단 측점선(사용자 41번). + +못박는 것 + ① 좌측 목록 노면공 = 범위 카드 전부 테두리 + 포장층 강조 · 카드 하나 안 고름 · 종단 측점선 없음. + ② 카드 안 노면공 형상 누름 = ① 과 같음(고른 카드도 풂 · 카드 고름 리스너 안 돎). + ③ 카드 하나 누름(형상 밖) = 목록 고름 · 범위 강조 풂 · 그 카드 하나 · 종단 측점선 = 그 카드 · 세션 sid 없음. + 같은 카드 재누름 = 풂 · 관 자리 카드 = 관. + +상태는 `helper_165_pavement_flow.cjs`(진짜 SPN) · 형상 → 노면공 찾기는 `helper_189_1_pave_shape.cjs`. +""" + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _node(name: str) -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, name)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def flow() -> dict: + return _node("helper_165_pavement_flow.cjs") + + +@pytest.fixture(scope="module") +def shape() -> dict: + return _node("helper_189_1_pave_shape.cjs") + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +PAVE = { + "form": "pave", + "pill": "pave", + "paveLayer": "pave", + "paveCards": 12, + "card": None, + "sid": "pave", + "line": None, +} + + +def _card(at): + return { + "form": None, + "pill": None, + "paveLayer": None, + "paveCards": 0, + "card": at, + "sid": None, + "line": None if at is None else f"s{at}", + } + + +def test_1_목록_고름은_범위_카드_전부_종단_선_없음(flow) -> None: + assert flow["r1"] == PAVE + + +def test_2_형상_누름은_목록_고름과_같음(flow) -> None: + assert flow["r2first"] == PAVE # 처음부터 형상 + assert flow["r2"] == PAVE # 카드 하나 고른 뒤 형상 = 옛 카드 풂 + assert flow["r2reveal"] == "s40" + + +def test_3_카드_하나는_목록_고름과_범위_강조를_풂(flow) -> None: + assert flow["r3"] == _card(140) + assert flow["r3b"] == _card(100) + assert flow["r3re"] == _card(None) # 같은 카드 재누름 = 풂 + pipe = flow["r3pipe"] + assert (pipe["form"], pipe["pill"], pipe["paveCards"], pipe["sid"]) == ( + "pipe-80.17", + "pipe-80.17", + 0, + None, + ) + + +def test_누른_포장층은_범위_표식으로_그_노면공(shape) -> None: + assert shape["pave"] == "pave" + assert shape["pave2"] == "pave2" # 겹친 노면공 = 누른 층 + for key in ("plainLayer", "outside", "noTarget", "unknown"): + assert shape[key] is None, key + + +def test_배선() -> None: + view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + # 형상 누름은 카드 고름보다 먼저 가로챔(잡기 단계 · 전파 멈춤) + body = re.search( + r"const pave = pavementAt\(event\.target, spanMarks\);(.*?)true,\n", view, re.S + ) + assert body, "형상 누름 배선 없음" + assert "event.stopPropagation();" in body.group(1) + assert "pavementSelectListener?.(pave.structure_id);" in body.group(1) + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert ( + "sectionView.setPavementSelectListener((id) => structuresPanel.selectStructure(id));" + in cross + ) + # 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김만) + assert "const preferred = structuresPanel.preferred() !== null;" in cross + assert ( + "const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null };" + in cross + ) + profile = _read("A00_Common", "design", "design_view_profile.ts") + assert "const structurePick = !!readSelection(activeProjectId)?.structureId;" in profile