diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 5dbd9f0c3..e1ae1e94c 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -115,13 +115,14 @@ export async function mountCrossView( const crossHalfWidthField = crossView.field; /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */ - const focusStationAt = (chainageM: number): void => { + const focusStationAt = (chainageM: number, revealOnly = false): void => { const sections = sectionDetail?.cross_sections; if (!sections?.length) return; const nearest = sections.reduce((best, entry) => Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best, ); - sectionView.focusStation(nearest.station_id); + if (revealOnly) sectionView.revealStation(nearest.station_id); + else sectionView.focusStation(nearest.station_id); }; /** 그 누가거리를 가진 측점 카드(관 찾는 거리 한 값). */ const ownerAt = (chainageM: number) => @@ -186,8 +187,8 @@ export async function mountCrossView( stationInterval: () => stationInterval ?? 20, // 벽·구체를 누르는 중이면 그 누름이 카드를 고른다 — 폼이 구조물을 열며 카드를 먼저 // 고르면 뒤이은 카드 선택이 「같은 측점 재클릭 = 해제」로 먹힌다. - focusChainage: (chainageM) => { - if (!isWallSelecting()) focusStationAt(chainageM); + focusChainage: (chainageM, revealOnly) => { + if (!isWallSelecting()) focusStationAt(chainageM, revealOnly); }, releaseCard: () => releaseCross(), markStructure: (structureId) => sectionView.selectMark(structureId), diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 375e616db..25c0eaded 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -84,8 +84,8 @@ export interface B06StructuresPanelDeps { wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null; /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ stationInterval: () => number; - /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */ - focusChainage: (chainageM: number) => void; + /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다(revealOnly = 고르지 않고 스크롤만 · 177-4). */ + focusChainage: (chainageM: number, revealOnly?: boolean) => void; /** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */ releaseCard?: () => void; /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */ @@ -216,11 +216,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } currentChainageM = null; // 폼 = 이 구조물 — 옛 관을 붙든 채 남지 않게(165-2) // 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다. - preferredId = structure.structure_id ?? null; - deps.focusChainage(focusOf(structure)); + // 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 그 카드로 스크롤만 · 폼 유지(177-4) + const pave = !coverable(structure); + const id = structure.structure_id ?? null; + preferredId = id; + deps.focusChainage(focusOf(structure), pave); preferredId = null; // 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2) - deps.markStructure?.(structure.structure_id ?? null); + deps.markStructure?.(id); + // 카드 고름 리스너를 안 거치니 세션 고름은 여기서(B05 로 가도 그 노면공) + const at = { chainageM: focusOf(structure), structureId: id }; + if (pave) selectDesign(deps.projectId, at, { from: "b06" }); }, getInterval: deps.stationInterval, // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index b4f916667..805ac4c5d 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -235,7 +235,7 @@ export function createSectionView( /** * 고른 구간형 구조물(옹벽 · 독립 흙막이 · 사토장)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1). - * 노면공(G군)은 테두리 대신 범위 카드 안 그 노면공의 포장층만 강조(152-5) — 겹친 노면공은 범위 표식으로 하나만. + * 노면공(G군)도 테두리 + 범위 카드 안 그 노면공의 포장층 강조(152-5 · 177-4) — 겹친 노면공은 범위 표식으로 하나만. * 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다. */ let spanMarks: ReadonlyArray = []; @@ -257,7 +257,7 @@ export function createSectionView( : undefined; const span = inSpan(section, mark); const pave = span && isPavement(spanMarkId); - card.classList.toggle("b06-cross-card--span", span && !pave); + card.classList.toggle("b06-cross-card--span", span); highlightPavement(card, pave ? mark : undefined); }; const syncSpanHighlights = (): void => { @@ -576,6 +576,15 @@ export function createSectionView( ); } }, + revealStation(stationId) { + // 노면공 고름 = 카드 하나 안 고름 · 옛 카드는 리스너 없이 풂(폼 유지) · 그 카드로 스크롤만(177-4) + const previous = selectedStationId; + selectedStationId = null; + activeAreaKey = null; + markCard(previous); + profile.showCard(null); + revealCard(stationId, "smooth"); + }, selectMark: (structureId, span = true) => { // span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 포장층은 테두리가 아니라 늘 켬(165-2) spanMarkId = span || isPavement(structureId) ? structureId : null; diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 1db878a43..0e7439732 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -42,6 +42,8 @@ export interface SectionViewController { /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; + /** 카드를 고르지 않고 스크롤만 — 고른 카드는 알림 없이 풂(노면공 목록 고름 · 177-4). */ + revealStation: (stationId: string) => void; /** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). * `span` = 구간형 범위 안 카드 전부 테두리(기본) · 끄면 카드는 고른 측점 하나(137-4). */ selectMark: (structureId: string | null, span?: boolean) => void; diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 407a783ef..75a3f17e1 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -202,6 +202,13 @@ const view = { profile.showCard(CARDS.find((card) => card.station_id === this.selected) ?? null); if (this.selected !== previous) this.listener(this.selected); }, + revealed: null, + revealStation(id) { + // 노면공 목록 고름 = 카드 하나 안 고름 · 리스너 없이 풂 · 스크롤만(177-4 · Section_View `revealStation`) + this.selected = null; + profile.showCard(null); + this.revealed = id; + }, focusStation(id) { if (id === null) this.selected = null; else if (this.selected !== id) this.selectStation(id); @@ -214,11 +221,12 @@ const view = { }; let wallSelecting = false; -const focusStationAt = (at) => { +const focusStationAt = (at, revealOnly = false) => { const nearest = CARDS.reduce((best, card) => Math.abs(card.chainage_m - at) < Math.abs(best.chainage_m - at) ? card : best, ); - view.focusStation(nearest.station_id); + if (revealOnly) view.revealStation(nearest.station_id); + else view.focusStation(nearest.station_id); }; const current = { structures: [PAVE] }; @@ -248,6 +256,7 @@ async function build(list) { form.id = null; form.pipe = null; view.selected = null; + view.revealed = null; view.spanMarkId = null; profile.markedId = null; profile.pill = null; @@ -256,8 +265,8 @@ async function build(list) { shared: { view: () => "cross" }, detail: () => ({ cross_sections: CARDS }), stationInterval: () => 20, - focusChainage: (at) => { - if (!wallSelecting) focusStationAt(at); + focusChainage: (at, revealOnly) => { + if (!wallSelecting) focusStationAt(at, revealOnly); }, markStructure: (id) => view.selectMark(id), onMarks: (next, types) => { @@ -308,7 +317,11 @@ const wallPick = (cardAtM, ownerM) => { await build([PAVE, { structure_id: "sign", type_id: "hazard_sign", chainage_m: 127.86 }]); listPick("pave"); out.step1 = snap(); - cardPick(40); // 고름이 이미 연 범위 첫 카드 재누름(브레인 ORCA ②) + out.reveal1 = view.revealed; // 고르지 않고 스크롤만 한 카드(177-4) + out.pick1 = selection.readSelection(PID); + cardPick(40); // 스크롤만 한 범위 첫 카드 누름 + out.step2first = snap(); + cardPick(40); // 고른 범위 카드 재누름(167-3 · 브레인 ORCA ②) out.step2re = snap(); cardPick(140); out.step2 = snap(); @@ -318,6 +331,7 @@ const wallPick = (cardAtM, ownerM) => { out.step3 = snap(); pillPick("pave"); out.step4 = snap(); + out.reveal4 = view.revealed; cardPick(80); // 관 자리 카드 = 관(지금처럼) out.pipeCard = snap(); pillPick("pave"); @@ -339,6 +353,7 @@ const wallPick = (cardAtM, ownerM) => { await build([PAVE, PAVE2]); listPick("pave2"); out.step6 = snap(); + out.reveal6 = view.revealed; cardPick(160); out.step6b = snap(); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_106_span_highlight_wording.py b/resources/tester/test_106_span_highlight_wording.py index aeec79260..e0b5f9c97 100644 --- a/resources/tester/test_106_span_highlight_wording.py +++ b/resources/tester/test_106_span_highlight_wording.py @@ -32,8 +32,8 @@ def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길 r"spanMarks = structures;\s+spanTypes = types;\s+syncSpanHighlights\(\);", VIEW ) assert VIEW.count("applySpan(card, section);") == 2 - # 152-5 노면공(G군)은 테두리 대신 포장층 - assert 'card.classList.toggle("b06-cross-card--span", span && !pave);' in VIEW + # 152-5 노면공(G군) 포장층 · 177-4 범위 카드 테두리도 함께 + assert 'card.classList.toggle("b06-cross-card--span", span);' in VIEW def test_측점_고름은_카드_하나_목록_고름은_여럿(): diff --git a/resources/tester/test_165_pavement_flow.py b/resources/tester/test_165_pavement_flow.py index 7ab3b105e..e03dfe7c6 100644 --- a/resources/tester/test_165_pavement_flow.py +++ b/resources/tester/test_165_pavement_flow.py @@ -2,7 +2,7 @@ """165 B06 노면공 고름 일관성 — 조사 K 재현 차례 6단계. 못박는 것(단계마다 폼 · 알약 · 포장층 · 고른 카드 · 세션 sid 가 같은 것을 가리킴) - 1. 목록 노면공 = 범위 안 카드 · 포장층 · 알약 · 폼 · sid 모두 노면공(기준점이 범위 경계여도 범위 밖 카드로 안 감). + 1. 목록 노면공 = 포장층 · 알약 · 폼 · sid 모두 노면공 · 카드는 안 고름(스크롤만 · 177-4 · 기준점이 범위 경계여도 범위 밖 카드로 안 감). 2. 범위 안 빈 카드(관 · 덮는 구조물 없음) = 노면공 그대로 · 카드만 옮김(165-1). 3. 고른 카드의 벽 = 관 폼 + 관 알약(알약이 꺼진 채 폼만 남지 않음 · 165-2) · 포장층 풂. 4. 알약 다시 = 1 과 같음. 관 자리 카드 = 관 · 범위 밖 카드 = 모두 풂(지금처럼). @@ -55,10 +55,12 @@ PIPE = {"form": "pipe-80.17", "pill": "pipe-80.17", "paveLayer": None, "paveCard def test_1_목록_노면공(out): - assert out["step1"] == _pave(40) # 기준점 30 = 범위 경계 → 범위 안 첫 카드(20 아님) + assert out["step1"] == _pave(None) # 카드 하나 안 고름(177-4) + 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) @@ -69,7 +71,7 @@ def test_3_벽은_관_폼과_관_알약_함께(out): def test_4_알약_다시_관_카드_범위_밖(out): - assert out["step4"] == _pave(40) + assert out["step4"] == _pave(None) assert out["pipeCard"] == {**PIPE, "card": 80, "sid": None} assert out["outside"] == { "form": None, @@ -83,17 +85,17 @@ def test_4_알약_다시_관_카드_범위_밖(out): def test_5_B05_복귀(out): - assert out["step5"] == _pave(40) + assert out["step5"] == _pave(None) def test_6_겹친_노면공(out): - assert out["step6"] == _pave(100, "pave2", 6) + assert out["step6"] == _pave(None, "pave2", 6) assert out["step6b"] == _pave(160, "pave2", 6) def test_배선(): panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") - assert "deps.focusChainage(focusOf(structure));" in panel + assert "deps.focusChainage(focusOf(structure), pave);" in panel assert "return { chainageM, structureId: openId };" in panel # 관 알약은 폼 뒤(폼이 연 구조물을 닫으며 알약을 끔) at = panel.index("section.selectPipeByChainage(hit.pipeChainageM);") diff --git a/resources/tester/test_177_4_pavement_span_box.py b/resources/tester/test_177_4_pavement_span_box.py new file mode 100644 index 000000000..b604678ee --- /dev/null +++ b/resources/tester/test_177_4_pavement_span_box.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""177-4 B06 노면공 목록 · 알약 고름 = 범위 카드 전부 테두리(박스) · 카드 하나 안 고름. + +못박는 것 + - 노면공 고름 = 범위 안 카드 전부 `b06-cross-card--span`(다른 구간형과 같음) + 포장층 강조 그대로. + - 카드 하나 고르기(`--selected`) 없음 · 범위 첫 카드로 스크롤만 · 옛 고른 카드는 리스너 없이 풂(폼 · 알약 유지). + - 세션 고름은 노면공 + 스크롤한 카드 측점(B05 로 가도 그 노면공). + +Node 헬퍼 `helper_165_pavement_flow.cjs`(진짜 SPN) 결과를 같이 씀. 화면은 사용자 확인. +""" + +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, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_165_pavement_flow.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_노면공_고름은_카드_안_고르고_스크롤만(out): + for step, reveal, layer, cards in ( + ("step1", "reveal1", "pave", 12), + ("step4", "reveal4", "pave", 12), + ("step6", "reveal6", "pave2", 6), + ): + snap = out[step] + assert snap["card"] is None # --selected 0 + assert snap["form"] == layer and snap["pill"] == layer # 폼 · 알약 유지 + assert snap["paveCards"] == cards # 범위 카드 전부 + assert out[reveal] in ("s40", "s100") + assert out["pick1"] == {"chainageM": 40, "structureId": "pave"} + + +def test_범위_카드_테두리는_노면공도(): + view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + assert 'card.classList.toggle("b06-cross-card--span", span);' in view + assert "highlightPavement(card, pave ? mark : undefined);" in view + + +def test_스크롤만_길은_리스너를_안_거침(): + view = _read("B06_Section", "B06_Section_UI_Section_View.ts") + body = re.search(r"revealStation\(stationId\) \{(.*?)\n \},", view, re.S) + assert body, "revealStation 없음" + text = body.group(1) + assert "selectedStationId = null;" in text + assert "profile.showCard(null);" in text + assert 'revealCard(stationId, "smooth");' in text + assert "stationSelectListener" not in text # 폼을 닫지 않음 + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "if (revealOnly) sectionView.revealStation(nearest.station_id);" in cross + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "const pave = !coverable(structure);" in panel + assert 'if (pave) selectDesign(deps.projectId, at, { from: "b06" });' in panel