fix(B05): 종단 측점선 고름 → 좌측 구조물 폼 = B06 카드 고름과 같은 규칙 · 같은 함수(216-1)

- 까닭: syncIrregularSelection 이 일반 측점이면 폼을 안 건드리고 끝남(옛 폼 남음 · 덮는 옹벽 안 열림) · 비정규 측점도 관 전용 selectPipeByChainage 만 불러 A군 수동 구조물 안 열림
- 측점 고름 포트 openAtStation = 폼 selectAt(resolveSelection) + coversAtStation(노면공 뺌) — B06 showAtChainage 와 한 규칙
- 고름 한 칸은 폼 동기화 뒤에 그 측점으로(폼 알림이 구조물 id 를 덮어 적지 않게 · 188-1)
- coversAtStation 을 design_selection 한 곳에 두고 B06 coverable 도 그것으로
- 새 시험 test_216_1_b05_station_panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
2026-10-10 15:31:59 +09:00
co-authored by Claude Opus 5.5
parent e2579fb4a6
commit 59f2d4463a
9 changed files with 325 additions and 10 deletions
+3
View File
@@ -97,6 +97,9 @@ export function resolveSelection<T extends SelectableStructure>(
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 {
+12 -1
View File
@@ -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),
@@ -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);
+8 -4
View File
@@ -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;
@@ -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;
@@ -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. */
@@ -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). */
@@ -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));
@@ -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