From 3d4909b37950b6cfe8010a0d5e590804a69285b1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 13:10:40 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05=C2=B7B06):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=20=EC=B8=A1=EC=A0=90=20=EA=B3=A0=EB=A6=84?= =?UTF-8?q?=EB=8F=84=20=EA=B3=A0=EB=A6=84=20=ED=95=9C=20=EC=B9=B8=EC=97=90?= =?UTF-8?q?=20=C2=B7=20=ED=9A=A1=EB=8B=A8=20=EC=9D=BC=EB=B0=98=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=EC=9D=84=20=EC=A2=85=EB=8B=A8=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=EC=84=A0=EC=9C=BC=EB=A1=9C(188-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원인: 종단 측점선 · 3D 띠 · 표에서 고른 일반 측점이 structure-pick 에 안 적혀 옛 배관 고름이 이김 - 선택 동기화가 측점 고름을 remember 포트로 적음(풂 = 비움 · 모르는 id 는 안 건드림) - 횡단 → 종단: 관 없는 자리면 그 측점선 · 3D 띠를 켬(graphTargetAt) - 새 시험 test_188_1_last_pick Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/design/design_view_profile.ts | 25 ++- B05_Profile/B05_Profile_UI_Selection.ts | 27 ++- resources/tester/helper_188_1_last_pick.cjs | 236 ++++++++++++++++++++ resources/tester/test_188_1_last_pick.py | 101 +++++++++ 4 files changed, 378 insertions(+), 11 deletions(-) create mode 100644 resources/tester/helper_188_1_last_pick.cjs create mode 100644 resources/tester/test_188_1_last_pick.py diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 7bb08fc1e..3d501ba50 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -25,13 +25,13 @@ import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profil import { leaveForDashboard } from "../b_missing_data_guard"; import { currentRoute, remountRoute } from "../router"; import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit"; -import { createSelectionSync } from "../../B05_Profile/B05_Profile_UI_Selection"; +import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; import { readStructurePick, restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { NO_SELECTION, PIPE_MATCH_M, nearestWithin, selectDesign } from "./design_selection"; +import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection"; import type { DesignPageHost } from "./design_page"; import { bindDesignView, @@ -123,14 +123,17 @@ export async function mountProfileView( profilePanel.setMinCoverPipes(pipes); restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. }; - /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2). */ + /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) · + * 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1). */ const selectGraphAt = (id: string | null, chainageM: number | null): void => { - const bare = - id === null && chainageM !== null - ? lastPipes?.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M) - : undefined; - if (bare) profilePanel.setSelectedStructure(`pipe-${bare.chainage_m.toFixed(2)}`); - else profilePanel.setSelectedStation(id); + const sections = currentSectionDetail?.cross_sections ?? []; + const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections); + if ("pipeChainageM" in target) { + profilePanel.setSelectedStructure(`pipe-${target.pipeChainageM.toFixed(2)}`); + return; + } + profilePanel.setSelectedStation(target.stationId); + if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId); }; // 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b). const profilePanel = createRouteProfilePanel( @@ -223,6 +226,10 @@ export async function mountProfileView( selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM), + regularChainage: (stationId) => + currentSectionDetail?.cross_sections.find((entry) => entry.station_id === stationId) + ?.chainage_m, + remember: (chainageM) => rememberPick(chainageM, null), }); /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 diff --git a/B05_Profile/B05_Profile_UI_Selection.ts b/B05_Profile/B05_Profile_UI_Selection.ts index d28add866..cc4f393ed 100644 --- a/B05_Profile/B05_Profile_UI_Selection.ts +++ b/B05_Profile/B05_Profile_UI_Selection.ts @@ -9,7 +9,7 @@ * 구조물(`origin: "pipe"`)일 때만 붙는다 — 다른 구조물에는 대응하는 유역이 없다. * ========================================================================== */ -import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { PIPE_MATCH_M, nearestWithin } from "../A00_Common/design/design_selection"; import { irregularStationId, isPipeStation, @@ -38,6 +38,10 @@ export interface SelectionSyncPorts { /** 3D 코리도 구조물 강조(누가거리 기준) — 좌측 폼·종단에서 골라도 3D가 따라온다 * (2026-09-04). 3D에서 부재를 집어 이미 그 측점을 고른 상태면 그대로 둔다. */ selectStructure3D?: (chainageM: number | null) => void; + /** 일반 측점 id → 누가거리(없으면 undefined — 고름 한 칸을 안 건드림) — 일반 측점 고름도 고름 한 칸에 남기는 데 씀(188-1). */ + regularChainage?: (stationId: string) => number | undefined; + /** 사용자 고름을 고름 한 칸(`structure-pick`)에 적음 — 마지막 고름 하나만 이김(188-1). */ + remember?: (chainageM: number | null) => void; } export interface SelectionSync { @@ -71,9 +75,13 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { syncIrregularSelection(stationId) { if (ports.isSyncing()) return; syncBasinHighlight(stationId); + const station = irregularOf(stationId); + // 측점선 · 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; - const station = irregularOf(stationId); ports.setSyncing(true); try { ports.selectSidebar(station ? station.chainage_m : null); @@ -113,3 +121,18 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { }, }; } + +/** 종단 그래프가 켤 것 — 측점 id 가 있으면 그 선 · 없으면 그 자리 관 알약(53-2) · 관도 없으면 + * 그 자리 측점선(188-1 · 횡단에서 고른 일반 측점이 종단으로 이어짐). */ +export function graphTargetAt( + stationId: string | null, + chainageM: number | null, + pipes: ReadonlyArray<{ chainage_m: number }>, + sections: ReadonlyArray<{ station_id: string; chainage_m: number }>, +): { pipeChainageM: number } | { stationId: string | null } { + if (stationId !== null || chainageM === null) return { stationId }; + const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m); + if (pipe) return { pipeChainageM: pipe.chainage_m }; + const card = nearestWithin(sections, chainageM, (entry) => entry.chainage_m); + return { stationId: card?.station_id ?? null }; +} diff --git a/resources/tester/helper_188_1_last_pick.cjs b/resources/tester/helper_188_1_last_pick.cjs new file mode 100644 index 000000000..ba4d3c450 --- /dev/null +++ b/resources/tester/helper_188_1_last_pick.cjs @@ -0,0 +1,236 @@ +/* PLAN 188-1 단위검증 헬퍼 — 종단(B05) ↔ 횡단(B06) 오갈 때 마지막 고름 하나만 이김. + * 화면 TS(`B05_Profile_UI_Selection` · `design_selection` · 넘김 · 카드 찾기)를 그 자리에서 + * 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. 짝 = `test_188_1_last_pick.py`. + * 포트 배선은 `design_view_profile.ts` 와 같은 꼴(remember = 고름 한 칸 · 3D 띠 리스너 = 선택 동기화). + */ +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; +globalThis.requestAnimationFrame = (fn) => fn(); + +// 비정규 측점 모듈은 설정 별칭(@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 pickSession = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"), +); +const pickPage = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structure_Pick.ts")); + +const P = "proj-188"; +// 카드(횡단) = 일반 측점 + 배관 투영 측점(irregular:p1 · 33.4) · 옹벽 60~80 · 물넘이포장(측점선 없는 관 50) +const cards = [ + ["s0", 0], + ["s20", 20], + ["irregular:p1", 33.4], + ["s40", 40], + ["s60", 60], + ["s80", 80], + ["s100", 100], +].map(([station_id, chainage_m]) => ({ station_id, chainage_m, ford: null, box: null })); +const detail = { cross_sections: cards }; +const irregular = [{ id: "p1", chainage_m: 33.4, origin: "pipe", structure: "배관" }]; +const pipes = [{ chainage_m: 33.4 }, { chainage_m: 50 }]; +const structures = [ + { structure_id: "wall", placement: "interval", start_m: 60, end_m: 80, chainage_m: 60 }, +]; + +// ── B05 종단 보기 흉내 — design_view_profile 의 포트 배선 그대로 ───────────────── +let syncing = false; +const graph = { station: null, mark: null }; +let marker = null; +const sb = sync.createSelectionSync({ + stations: () => irregular, + isSyncing: () => syncing, + setSyncing: (v) => { + syncing = v; + }, + // 3D 띠 고름은 리스너로 선택 동기화를 되부름(onStationSelectionChange) + selectMarker: (id) => { + marker = id; + sb.syncIrregularSelection(id); + }, + selectGraph: (id, c) => { + const target = sync.graphTargetAt(id, c ?? null, pipes, cards); + if ("pipeChainageM" in target) graph.mark = `pipe-${target.pipeChainageM.toFixed(2)}`; + else { + graph.station = target.stationId; + // 관도 없는 자리 = 그 측점선 · 3D 띠(view 의 selectGraphAt 과 같음) + if (id === null && target.stationId !== null) { + marker = target.stationId; + sb.syncIrregularSelection(target.stationId); + } + } + }, + selectSidebar: () => undefined, + selectBasin: () => undefined, + regularChainage: (id) => cards.find((c) => c.station_id === id)?.chainage_m, + remember: (c) => + sel.selectDesign(P, c === null ? sel.NO_SELECTION : { chainageM: c, structureId: null }, { + from: "b05", + }), +}); + +/** B05 사용자 고름 — 측점선 · 3D 띠 · 표(선택 동기화) · 알약 · 목록(직접 적음). */ +const b05 = { + line: (id) => sb.syncIrregularSelection(id), + pill: (c) => sel.selectDesign(P, { chainageM: c, structureId: null }, { from: "b05" }), + structure: (sid, c) => sel.selectDesign(P, { chainageM: c, structureId: sid }, { from: "b05" }), +}; + +/** 횡단 탭으로 — 넘김값으로 연 카드 id(없으면 null). */ +function toB06() { + let focused = null; + const handoff = pickSession.readStructurePick(P); + pickPage.applyStructurePick( + handoff, + detail, + { + focusStation: (id) => { + focused = id; + }, + refreshCard: () => undefined, + revetSelect: () => undefined, + fordSelect: () => undefined, + boxSelect: () => undefined, + }, + structures, + ); + return focused; +} + +/** 종단 탭으로 — 넘김값을 되살린 뒤 그래프 · 3D · 고름 한 칸. */ +function toB05() { + graph.station = null; + graph.mark = null; + marker = null; + const opened = []; + pickSession.restoreStructurePick( + { select: () => undefined }, + P, + { + hasSelection: () => opened.length > 0, + selectAt: (s) => opened.push(s.structureId), + }, + sb.selectStationOfPipe, + ); + return { graph: { ...graph }, marker, opened, pick: sel.readSelection(P) }; +} + +const reset = () => sel.selectDesign(P, sel.NO_SELECTION); +const out = {}; + +// ① 배관 → 일반 측점 → 횡단 = 일반 측점(사용자 재현) +reset(); +b05.line("irregular:p1"); +b05.line("s20"); +out.pipeThenStation = toB06(); +reset(); +b05.pill(33.4); // 알약으로 고른 배관도 같음 +b05.line("s20"); +out.pillThenStation = toB06(); + +// ② 일반 측점 → 배관 → 횡단 = 배관 +reset(); +b05.line("s20"); +b05.line("irregular:p1"); +out.stationThenPipe = toB06(); + +// ③ 구조물 끼움 — 배관 → 구조물 → 일반 측점 = 일반 측점 · 일반 측점 → 구조물 = 구조물 시작 카드 +reset(); +b05.pill(33.4); +b05.structure("wall", 60); +b05.line("s100"); +out.structureThenStation = toB06(); +reset(); +b05.line("s100"); +b05.structure("wall", 60); +out.stationThenStructure = toB06(); +reset(); +b05.structure("wall", 60); +b05.line("irregular:p1"); +out.structureThenPipe = toB06(); + +// ④ 고름 풂 · 모르는 측점 id +reset(); +b05.line("irregular:p1"); +b05.line(null); +out.released = pickSession.readStructurePick(P); +reset(); +b05.line("s20"); +b05.line("ghost"); +out.unknownKeeps = pickSession.readStructurePick(P)?.at ?? null; + +// ⑤ 횡단 → 종단 — B06 카드가 적은 값을 종단이 그대로 켬 · 되살리기는 고름 한 칸을 안 덮음 +reset(); +sel.selectDesign(P, { chainageM: 40, structureId: null }, { from: "b06" }); +out.b06Station = toB05(); +reset(); +sel.selectDesign(P, { chainageM: 33.4, structureId: null }, { from: "b06" }); +out.b06Pipe = toB05(); +reset(); +sel.selectDesign(P, { chainageM: 50, structureId: null }, { from: "b06" }); +out.b06BarePipe = toB05(); +reset(); +sel.selectDesign(P, { chainageM: 60, structureId: "wall" }, { partKey: "own", from: "b06" }); +out.b06Structure = toB05(); + +// ⑥ 오가기 — 종단 일반 측점 → 횡단 → 횡단 카드 → 종단 → 종단 배관 → 횡단 +reset(); +b05.line("s20"); +const trip = [toB06()]; +sel.selectDesign(P, { chainageM: 80, structureId: null }, { from: "b06" }); +trip.push(toB05().graph.station); +b05.line("irregular:p1"); +trip.push(toB06()); +b05.line("s0"); +trip.push(toB06()); +out.roundTrip = trip; + +// ⑦ 그래프 표적 — 측점 id 우선 · 관 알약 · 그 자리 측점선 · 아무것도 없음 +out.graphTarget = { + byId: sync.graphTargetAt("s20", 33.4, pipes, cards), + bare: sync.graphTargetAt(null, 50.2, pipes, cards), + station: sync.graphTargetAt(null, 100.3, pipes, cards), + far: sync.graphTargetAt(null, 120, pipes, cards), + none: sync.graphTargetAt(null, null, pipes, cards), +}; +reset(); +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_188_1_last_pick.py b/resources/tester/test_188_1_last_pick.py new file mode 100644 index 000000000..2d6e1c865 --- /dev/null +++ b/resources/tester/test_188_1_last_pick.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +"""PLAN 188-1 — 종단(B05) ↔ 횡단(B06) 오갈 때 마지막 고름 하나만 이김. + +원인: 종단 측점선 · 3D 띠 · 표에서 고른 일반 측점은 고름 한 칸(`structure-pick`)에 안 적혀 +(`syncIrregularSelection` 이 일반 측점이면 바로 끝남) 옛 배관 고름이 남아 횡단이 배관 카드로 감. +횡단 → 종단은 관 · 구조물이 없는 자리면 그래프 측점선을 풀어 버림(`selectGraphAt` = null). +실제 TS 를 `helper_188_1_last_pick.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_188_1_last_pick.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 test_배관_다음_일반_측점이면_횡단은_일반_측점(out) -> None: + assert out["pipeThenStation"] == "s20" + assert out["pillThenStation"] == "s20" # 알약으로 고른 배관도 + + +def test_일반_측점_다음_배관이면_횡단은_배관(out) -> None: + assert out["stationThenPipe"] == "irregular:p1" + + +def test_구조물을_끼워도_마지막_고름이_이김(out) -> None: + assert out["structureThenStation"] == "s100" + assert out["stationThenStructure"] == "s60" # 옹벽 = 시작 측점 카드 + assert out["structureThenPipe"] == "irregular:p1" + + +def test_고름을_풀면_비우고_모르는_측점은_안_건드림(out) -> None: + assert out["released"] is None + assert out["unknownKeeps"] == 20 + + +def test_횡단_일반_측점은_종단_측점선과_3D_띠로(out) -> None: + station = out["b06Station"] + assert station["graph"] == {"station": "s40", "mark": None} + assert station["marker"] == "s40" + assert station["pick"] == {"chainageM": 40, "structureId": None} + + +def test_횡단_배관은_종단_배관_측점선_측점선_없는_관은_알약(out) -> None: + assert out["b06Pipe"]["graph"]["station"] == "irregular:p1" + assert out["b06BarePipe"]["graph"] == {"station": None, "mark": "pipe-50.00"} + + +def test_횡단_구조물은_종단에서_그_구조물을_열고_고름_한_칸을_안_덮음(out) -> None: + structure = out["b06Structure"] + assert structure["opened"] == ["wall"] + # 되살리며 3D 띠 리스너가 되불러도(동기화 중) 구조물 id · 부재키가 남음 + assert structure["pick"] == {"chainageM": 60, "structureId": "wall", "partKey": "own"} + + +def test_종단_횡단을_오가도_마지막_고름(out) -> None: + assert out["roundTrip"] == ["s20", "s80", "irregular:p1", "s0"] + + +def test_그래프_표적_차례(out) -> None: + target = out["graphTarget"] + assert target["byId"] == {"stationId": "s20"} + assert target["bare"] == {"pipeChainageM": 50} + assert target["station"] == {"stationId": "s100"} + assert target["far"] == {"stationId": None} + assert target["none"] == {"stationId": None} + + +def test_종단_보기_배선() -> None: + page = _read("A00_Common", "design", "design_view_profile.ts") + assert "regularChainage:" in page + assert "remember: (chainageM) => rememberPick(chainageM, null)" in page + assert "graphTargetAt(id, chainageM, lastPipes ?? [], sections)" in page