diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index de688e943..0e16185a5 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -97,6 +97,9 @@ export function resolveSelection( return { pipeChainageM: null, structureId: hit?.structure_id ?? null }; } +/** 측점 하나 고름(횡단 카드 · 종단 측점선)이 덮어 열어도 되는 군 — 노면공(G군)은 뺌(152-5 · B05 = B06 216-1). */ +export const coversAtStation = (group: string | undefined): boolean => group !== "G"; + /* ── 세션 값 + 알림 ─────────────────────────────────────────────────────── */ interface Stored { diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 4124b09a5..9a1fe7824 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -31,7 +31,13 @@ import { restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { NO_SELECTION, nearestWithin, readSelection, selectDesign } from "./design_selection"; +import { + coversAtStation, + NO_SELECTION, + nearestWithin, + readSelection, + selectDesign, +} from "./design_selection"; import type { DesignPageHost } from "./design_page"; import { bindDesignView, @@ -223,6 +229,11 @@ export async function mountProfileView( selectMarker: (id) => viewer.markers.selectStation(id), selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null), selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM), + // 측점선 · 3D 띠 고름 = 횡단 카드 고름(B06 showAtChainage)과 같은 규칙 · 같은 함수(216-1) + openAtStation: (chainageM) => + panel.structures.selectAt({ chainageM, structureId: null }, (entry) => + coversAtStation(bridge.typeGroup(entry.type_id)), + ), selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index c52541f09..296298433 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -361,6 +361,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { }, /** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */ irregularStations: () => withoutFordPavement(irregularStations), + /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ + typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group, /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */ setPipes(pipes: PipeProjection[]): void { pipeStations = pipesToStations(pipes); diff --git a/B05_Profile/B05_Profile_UI_Selection.ts b/B05_Profile/B05_Profile_UI_Selection.ts index cc4f393ed..23efb7f6f 100644 --- a/B05_Profile/B05_Profile_UI_Selection.ts +++ b/B05_Profile/B05_Profile_UI_Selection.ts @@ -27,6 +27,9 @@ export interface SelectionSyncPorts { selectGraph: (stationId: string | null, chainageM?: number | null) => void; /** 좌측 구조물 폼 선택(누가거리 기준). */ selectSidebar: (chainageM: number | null) => void; + /** 측점 하나 고름(측점선 · 3D 띠) → 좌측 폼 — 횡단 카드 고름과 같은 규칙(관 → 덮는 구조물 · 노면공 뺌 → + * 없으면 폼 비움 · 216-1). 없으면 selectSidebar. */ + openAtStation?: (chainageM: number | null) => void; /** 배수유역도 세부유역 강조(누가거리 기준). */ selectBasin: (chainageM: number | null) => void; /** 배수유역도 계획선 위 측점 마킹(누가거리 기준). 유역 없는 구조물도 위치를 보여 준다. */ @@ -79,17 +82,18 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { // 측점선 · 3D 띠 · 표 고름도 고름 한 칸에 — 일반 측점을 안 적어 옛 배관 고름이 이겼음(188-1) const at = stationId === null ? null : (station?.chainage_m ?? ports.regularChainage?.(stationId)); - if (at !== undefined) ports.remember?.(at); - // 규칙 측점을 고른 것이면 구조물 폼은 건드리지 않는다(고를 항목이 없다). - if (stationId !== null && !stationId.startsWith(prefix)) return; + if (at === undefined) return; // 모르는 측점 — 폼 · 고름 한 칸 안 건드림 ports.setSyncing(true); try { - ports.selectSidebar(station ? station.chainage_m : null); + // 일반 측점도 폼이 따라감 — 일반 측점이면 폼을 안 건드려 옛 폼이 남았음(B06 카드 규칙 · 216-1) + (ports.openAtStation ?? ports.selectSidebar)(at); ports.selectPipeForm?.(station && isPipeStation(station) ? station.chainage_m : null); ports.selectStructure3D?.(station ? station.chainage_m : null); } finally { ports.setSyncing(false); } + // 폼이 연 구조물 · 푼 관 알림이 적은 고름을 이 측점으로 덮음 — 마지막 고름 하나(188-1) + ports.remember?.(at); }, selectStationOfPipe(chainageM) { if (ports.isSyncing()) return; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 6ea28ae39..2b7e97e5e 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -617,8 +617,8 @@ export function createStructuresSection( loadPipeForm(pipe); }, resolveAt: (selection) => resolveSelection(selection, pipeFacilities, structures), - selectAt(selection) { - const hit = resolveSelection(selection, pipeFacilities, structures); + selectAt(selection, coverable) { + const hit = resolveSelection(selection, pipeFacilities, structures, coverable); if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM); else this.selectById(hit.structureId); return hit; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 7183dc422..589623338 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -174,8 +174,11 @@ export interface StructuresSection { /** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). */ resolveAt: (selection: DesignSelection) => ResolvedSelection; /** 고름 한 값으로 폼을 연다(관 → 구조물 순) — 어느 화면에서 골라도 같은 거리 · 같은 순서다. - * 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. */ - selectAt: (selection: DesignSelection) => ResolvedSelection; + * 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. `coverable` = 덮어 열어도 되는 구조물(측점 고름 · 216-1). */ + selectAt: ( + selection: DesignSelection, + coverable?: (entry: StructureInstance) => boolean, + ) => ResolvedSelection; /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ addAt: (chainageM: number, typeId: string) => void; /** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */ diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 0abc4bfc5..d3dbdd338 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -38,6 +38,7 @@ import { } from "../common_util/common_util_summary_item"; import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { + coversAtStation, coversChainage, NO_SELECTION, PIPE_MATCH_M, @@ -163,7 +164,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc let markTypes: StructureType[] = []; /** 카드 누름이 덮어 여는 구조물 — 노면공(G군)은 뺌(152-5 · 목록 · 알약 고름은 id 길로 그대로). */ const coverable = (entry: StructureInstance): boolean => - markTypes.find((type) => type.type_id === entry.type_id)?.group !== "G"; + coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group); /** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */ let preferredId: string | null = null; /** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */ diff --git a/resources/tester/helper_216_1_b05_station_panel.cjs b/resources/tester/helper_216_1_b05_station_panel.cjs new file mode 100644 index 000000000..ba1547ed4 --- /dev/null +++ b/resources/tester/helper_216_1_b05_station_panel.cjs @@ -0,0 +1,193 @@ +/* PLAN 216-1 단위검증 헬퍼 — B05 종단 측점선 고름 → 좌측 구조물 폼이 B06 카드 고름과 같게. + * 화면 TS(`B05_Profile_UI_Selection` · `design_selection`)를 그 자리에서 트랜스파일해 Node 로 돌림 · + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_216_1_b05_station_panel.py`. + * B05 포트 배선은 `design_view_profile.ts` 와 같은 꼴 · B06 은 `showAtChainage` 와 같은 꼴(resolveSelection + coverable). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +const session = new Map(); +const fakeStorage = { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; +globalThis.sessionStorage = fakeStorage; +globalThis.localStorage = fakeStorage; + +// 비정규 측점 모듈은 설정 별칭(@config)을 끌어오므로 쓰는 두 함수만 같은 꼴로 흉내 +const STUBS = { + "./B05_Profile_UI_IrregularStations": { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", + }, +}; + +function loadTs(file) { + const full = path.resolve(file); + 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) => + STUBS[request] ?? + (request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request)); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const sel = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts")); +const sync = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Selection.ts")); + +const P = "proj-216"; +// 측점 = 일반(s0~s200) + 관 투영(irregular:p1 · 33.4) + A군 수동 횡단수로(irregular:x1 · 140) +const cards = [0, 20, 40, 60, 80, 100, 120, 160, 180, 200].map((c) => ({ + station_id: `s${c}`, + chainage_m: c, +})); +const irregular = [ + { id: "p1", chainage_m: 33.4, origin: "pipe", structure: "배관" }, + { id: "x1", chainage_m: 140, structure: "노출형 횡단수로" }, +]; +const pipes = [{ chainage_m: 33.4 }]; +const types = [ + { type_id: "retaining_wall", group: "C" }, + { type_id: "cross_channel", group: "A" }, + { type_id: "pavement", group: "G" }, +]; +const structures = [ + { structure_id: "wall", type_id: "retaining_wall", start_m: 60, end_m: 80, chainage_m: 60 }, + { structure_id: "chan", type_id: "cross_channel", chainage_m: 140 }, + { structure_id: "pave", type_id: "pavement", start_m: 150, end_m: 190, chainage_m: 150 }, +]; +const groupOf = (typeId) => types.find((t) => t.type_id === typeId)?.group; + +/** 좌측 폼 흉내 — B05 `_Structures_Panel` 의 selectPipeByChainage · selectById · selectAt 꼴 그대로. */ +function makeForm(onSelect) { + const form = { pipe: null, structure: null }; + const panel = { + form, + selectPipeByChainage(c) { + if (c === null) { + form.pipe = null; + return; + } + const hit = pipes.find((p) => Math.abs(p.chainage_m - c) < sel.PIPE_MATCH_M); + if (!hit) return; + form.pipe = hit.chainage_m; + form.structure = null; + onSelect?.(null); + }, + selectById(id) { + if (id === null) { + form.pipe = null; + form.structure = null; + return; + } + form.pipe = null; + form.structure = id; + onSelect?.(structures.find((s) => s.structure_id === id) ?? null); + }, + selectAt(selection, coverable) { + const hit = sel.resolveSelection(selection, pipes, structures, coverable); + if (hit.pipeChainageM !== null) panel.selectPipeByChainage(hit.pipeChainageM); + else panel.selectById(hit.structureId); + return hit; + }, + }; + return panel; +} +const formOf = (panel) => + panel.form.pipe !== null ? `pipe-${panel.form.pipe.toFixed(2)}` : panel.form.structure; + +// ── B05 종단 보기 — design_view_profile 의 포트 배선 그대로 ───────────────────── +let syncing = false; +const rememberPick = (c, sid) => + sel.selectDesign(P, c === null ? sel.NO_SELECTION : { chainageM: c, structureId: sid }, { + from: "b05", + }); +// 폼이 구조물을 열면 view 의 onStructureSelect 가 그 구조물을 고름 한 칸에 적음(동기화 중이면 그 뒤 return) +const b05Panel = makeForm((structure) => { + if (structure) rememberPick(structure.chainage_m, structure.structure_id); +}); +const sb = sync.createSelectionSync({ + stations: () => irregular, + isSyncing: () => syncing, + setSyncing: (v) => { + syncing = v; + }, + selectMarker: () => undefined, + selectGraph: () => undefined, + selectSidebar: (c) => b05Panel.selectPipeByChainage(c), + openAtStation: (c) => + b05Panel.selectAt({ chainageM: c, structureId: null }, (entry) => + sel.coversAtStation(groupOf(entry.type_id)), + ), + selectBasin: () => undefined, + regularChainage: (id) => cards.find((c) => c.station_id === id)?.chainage_m, + remember: (c) => rememberPick(c, null), +}); + +// ── B06 카드 고름 — showAtChainage 와 같은 꼴(카드 = 관 투영은 가까운 카드) ────────── +const b06Panel = makeForm(); +const b06Coverable = (entry) => sel.coversAtStation(groupOf(entry.type_id)); +const b06Card = (chainageM) => { + const hit = sel.resolveSelection( + { chainageM, structureId: null }, + pipes, + structures, + b06Coverable, + ); + if (hit.pipeChainageM !== null) b06Panel.selectPipeByChainage(hit.pipeChainageM); + else b06Panel.selectById(hit.structureId); +}; + +const chainageOf = (id) => + irregular.find((s) => `irregular:${s.id}` === id)?.chainage_m ?? + cards.find((c) => c.station_id === id)?.chainage_m; + +/** 차례대로 고름 — 매 걸음 B05 폼 · B06 폼 · 고름 한 칸. */ +function run(ids) { + b05Panel.form.pipe = b05Panel.form.structure = null; + b06Panel.form.pipe = b06Panel.form.structure = null; + sel.selectDesign(P, sel.NO_SELECTION); + return ids.map((id) => { + sb.syncIrregularSelection(id); + const at = id === null ? null : chainageOf(id); + if (at !== undefined) b06Card(at); // 모르는 측점은 B06 카드가 없음 + return { id, b05: formOf(b05Panel), b06: formOf(b06Panel), pick: sel.readSelection(P) }; + }); +} + +const out = { + pipe: run(["irregular:p1"]), + // 관 폼이 열린 채 일반 측점 = 폼 비움(새 배치 준비) — 옛 B05 는 관 폼이 남았음 + pipeThenPlain: run(["irregular:p1", "s100"]), + // 옹벽 범위 안 일반 측점 = 옹벽 폼 · 고름 한 칸은 그 측점(구조물 id 없음) + wallStation: run(["s20", "s60"]), + wallMiddle: run(["s80"]), + // A군 수동 횡단수로 측점 = 그 구조물 폼 + channel: run(["irregular:x1"]), + // 노면공만 덮는 측점 = 노면공 안 엶(189-1 · 152-5) — 연 폼은 풂 + pave: run(["s60", "s160"]), + // 같은 측점 다시 = 풂(null) + release: run(["s60", null]), + // 모르는 측점 = 폼 · 고름 한 칸 안 건드림 + unknown: run(["s60", "ghost"]), + covers: { + G: sel.coversAtStation("G"), + C: sel.coversAtStation("C"), + none: sel.coversAtStation(undefined), + }, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_216_1_b05_station_panel.py b/resources/tester/test_216_1_b05_station_panel.py new file mode 100644 index 000000000..189766527 --- /dev/null +++ b/resources/tester/test_216_1_b05_station_panel.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +"""PLAN 216-1 — B05 종단 측점선 고름 → 좌측 구조물 폼이 B06 카드 고름과 같게. + +까닭: `syncIrregularSelection` 이 일반 측점이면 폼을 안 건드리고 끝났고(옛 폼이 남음 · 덮는 옹벽도 +안 열림), 비정규 측점도 관 전용 `selectPipeByChainage` 만 불러 A군 수동 구조물은 안 열렸음. +B06 카드 고름은 `showAtChainage` = `resolveSelection`(관 → 덮는 구조물 · 노면공 뺌 → 없으면 비움). +고침: B05 측점 고름도 같은 `resolveSelection` + 같은 `coversAtStation` 으로 폼을 엶(`openAtStation`). +실제 TS 를 `helper_216_1_b05_station_panel.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +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="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_216_1_b05_station_panel.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +def _forms(steps: list) -> list: + return [(step["b05"], step["b06"]) for step in steps] + + +def test_관_지점_측점선은_그_관_폼(out) -> None: + assert _forms(out["pipe"]) == [("pipe-33.40", "pipe-33.40")] + + +def test_관_폼이_열린_채_빈_일반_측점이면_폼을_비움(out) -> None: + assert _forms(out["pipeThenPlain"])[-1] == (None, None) + + +def test_일반_측점은_그_측점을_덮는_구조물_폼(out) -> None: + assert _forms(out["wallStation"]) == [(None, None), ("wall", "wall")] + assert _forms(out["wallMiddle"]) == [("wall", "wall")] + assert _forms(out["channel"]) == [("chan", "chan")] # A군 수동 구조물 측점 + + +def test_노면공은_측점_고름으로_안_엶(out) -> None: + assert _forms(out["pave"]) == [("wall", "wall"), (None, None)] + assert out["covers"] == {"G": False, "C": True, "none": True} + + +def test_다시_누름_풂과_모르는_측점(out) -> None: + assert _forms(out["release"])[-1] == (None, None) + assert out["release"][-1]["pick"] is None + assert out["unknown"][-1]["b05"] == "wall" # 모르는 측점은 폼 그대로 + assert out["unknown"][-1]["pick"] == {"chainageM": 60, "structureId": None} + + +def test_고름_한_칸은_측점_구조물_id_없음(out) -> None: + # 폼이 옹벽을 열어도(목록 고름 알림이 구조물을 적어도) 마지막은 그 측점(188-1 · B06 카드와 같음) + for key in ("wallStation", "wallMiddle", "channel", "pave", "pipeThenPlain"): + for step in out[key]: + assert step["pick"]["structureId"] is None, key + assert step["pick"]["chainageM"] in (20, 33.4, 60, 80, 100, 140, 160), key + + +def test_배선() -> None: + sync = _read("B05_Profile", "B05_Profile_UI_Selection.ts") + assert "(ports.openAtStation ?? ports.selectSidebar)(at);" in sync + assert "if (stationId !== null && !stationId.startsWith(prefix)) return;" not in sync + # 고름 한 칸은 폼 동기화 뒤에 적음 + assert sync.index("ports.remember?.(at);") > sync.index("ports.setSyncing(false);") + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "openAtStation: (chainageM) =>" in view + assert "panel.structures.selectAt({ chainageM, structureId: null }, (entry) =>" in view + assert "coversAtStation(bridge.typeGroup(entry.type_id))" in view + b06 = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "coversAtStation(markTypes.find((type) => type.type_id === entry.type_id)?.group)" in b06 + assert "resolveSelection(selection, pipeFacilities, structures, coverable)" in b06 + panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") + assert "resolveSelection(selection, pipeFacilities, structures, coverable)" in panel