feat(514-1 알약 우클릭 삭제): 측점선 없는 구조물(대피로 · 노출형 횡단수로 등)도 종단 알약 · 평면 마커 우클릭 「구조물 삭제」 — 513-3 주인 가르기를 알약 id 로(removeMarkOwner · 관 알약 = 관 지점 정본 · 그 밖 = 좌측 칸 [삭제]와 같은 길) · B05 종단 보기 · B06 횡단 보기 같은 길(onStructureMarkRemove) · 캐시에만 · [저장] 때 나감 · 우클릭 삭제 글 = 관 · 세월교 · BOX 도 「구조물 삭제」 하나 · 시험 test_514_1 · test_513_1 글 기대 고침 — 요구 328-1 · 328-2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 21:09:08 +09:00
co-authored by Claude Opus 5.5
parent 286dc72e1b
commit 7e39403b52
14 changed files with 270 additions and 35 deletions
@@ -26,6 +26,7 @@ const STUBS = {
},
"./B05_Profile_Api_Structures": { structureAnchorM: (s) => s.chainage_m ?? 0 },
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 },
"./B05_Profile_UI_RouteEdit_StructureMarks": { planMarkAt: () => null }, // 마커 없음(514-1 몫)
};
const { loadTs } = makeLoader({ root: path.join(__dirname, "..", ".."), stubs: STUBS, ui });
const shared = loadTs(path.join(B05, "B05_Profile_UI_Profile_Structures.ts"));
@@ -0,0 +1,119 @@
/* 514-1 알약 · 평면 마커 우클릭 「구조물 삭제」(요구 328-1) — `design_pipe_ids.removeMarkOwner` ·
* `_RouteEdit_StructureMenu` 를 Node 로 돌려 JSON 한 줄(`test_514_1_mark_remove.py` 가 판정).
* 관 장부 · 메뉴 판 · 마커 집기는 가짜 · 주인 가르기 · 평면 항목 짓기는 진짜.
* node helper_514_1_mark_remove.cjs
*/
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
const B05 = path.join(ROOT, "B05_Profile");
const opens = [];
const calls = [];
const ui = () => ({
createMapContextMenu: () => ({
element: { parentElement: null },
open: (x, y, items) => opens.push({ x, y, items }),
close() {},
contains: () => false,
}),
});
// 평면 마커 = 측점선 없는 대피로 20 m(화면 40 px)
const MARK = { id: "evac-1", chainageM: 20 };
const STUBS = {
"../b_page_state": {
readState: () => ({ next: 4, ids: [{ id: "pipe-s3", at: 55 }] }),
writeState() {},
},
"./design_selection": {
PIPE_MATCH_M: 0.51,
isNear: () => false,
nearestWithin: () => null,
readSelection: () => null,
selectDesign() {},
},
common_util_pick_tag: { setPipeTagSource() {} },
"../A00_Common/touch_bridge": { pickPx: (v) => v },
"./B05_Profile_UI_IrregularStations": { irregularStationId: (id) => `irr:${id}` },
"./B05_Profile_Api_Structures": { structureAnchorM: (s) => s.chainage_m ?? 0 },
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 },
"./B05_Profile_UI_RouteEdit_StructureMarks": {
planMarkAt: (_c, _l, toScreen, px, py) => {
const [x, y] = toScreen([MARK.chainageM, 0]);
const distance = Math.hypot(x - px, y - py);
return distance <= 10 ? { mark: MARK, distance } : null;
},
},
};
const { loadTs } = makeLoader({ root: ROOT, stubs: STUBS, ui });
const ids = loadTs(path.join(ROOT, "A00_Common", "design", "design_pipe_ids.ts"));
ids.usePipeIds("p1");
const shared = loadTs(path.join(B05, "B05_Profile_UI_Profile_Structures.ts"));
const plan = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_StructureMenu.ts"));
const out = {};
// 알약 주인 가르기 — 관 세션 id · 옛 자리 이름 관 = 관 지점 · 그 밖 = 구조물 id
const remove = {
pipe: (m) => calls.push(["pipe", m]),
structure: (id) => calls.push(["structure", id]),
};
["pipe-s3", "pipe-12.50", "evac-1", "cross-2"].forEach((id) => ids.removeMarkOwner(id, remove));
out.owners = calls.splice(0);
function fakeElement() {
const listeners = {};
return {
isConnected: true,
addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn),
getBoundingClientRect: () => ({ left: 0, top: 0 }),
append(child) {
child.parentElement = this;
},
parentElement: null,
fire(type, clientX, clientY) {
(listeners[type] || []).forEach((fn) =>
fn({ clientX, clientY, target: null, preventDefault() {} }),
);
},
};
}
// 종단 그래프 = 재료(관 측점 40 m 하나)
const host = fakeElement();
shared.mountStructureMenu(host, {
stations: [{ id: "pipe-1", chainage_m: 40, origin: "pipe" }],
x: (m) => m * 2,
chainageAt: (px) => px / 2,
maxChainageM: 100,
onRemove: (station) => calls.push(["remove", station.id]),
onRemoveMark: (id) => calls.push(["removeMark", id]),
onAddPipe: () => {},
structureGroups: () => [{ label: "A군", types: [{ type: { name: "배수관", type_id: "pipe" } }] }],
onAddStructureType: () => {},
});
const canvas = fakeElement();
canvas.parentElement = fakeElement();
const line = [
[0, 0],
[100, 0],
];
plan.bindPlanStructureMenu(
{
canvas,
toScreen: (v) => [v[0] * 2, v[1] * 2],
unrotate: (x, y) => [x, y],
state: () => ({ plannedLine: line, planned: line }),
},
() => -1,
);
const planAt = (x, y) => {
canvas.fire("contextmenu", x, y);
const items = opens.at(-1).items;
return items.map((item) => (Array.isArray(item) ? item[0] : item.label));
};
out.planMark = planAt(41, 3); // 대피로 마커
opens.at(-1).items[0][1]();
out.planPipe = planAt(81, 2); // 관 측점선 = 측점 먼저
opens.at(-1).items[0][1]();
out.planEmpty = planAt(140, 2); // 빈 자리 = 추가 메뉴
out.calls = calls;
process.stdout.write(JSON.stringify(out));
@@ -32,7 +32,7 @@ def got() -> dict:
def test_평면_메뉴는_종단_메뉴와_같음(got) -> None:
assert got["liveBefore"] is True # 종단 그래프가 서기 전 = 재료 없음
assert got["profileEmpty"] == got["planEmpty"] == [["A군", ["배수관"]]]
assert got["profilePipe"] == got["planPipe"] == ["배관 삭제"]
assert got["profilePipe"] == got["planPipe"] == ["구조물 삭제"] # 글 하나(514-1)
assert got["planPlus"] == ["측점 삭제"]
assert got["planMenuAt"] == [141, -4]
@@ -0,0 +1,65 @@
"""514-1 알약 · 평면 마커 우클릭 「구조물 삭제」(요구 328-1 · 328-2) — 측점선 없는 구조물(대피로 · 노출형 횡단수로)도
알약 · 평면 마커 우클릭으로 지움 · 513-3 과 같은 주인 가르기(`removeMarkOwner`) · 삭제 글 = 「구조물 삭제」 하나.
TS 셈은 `helper_514_1_mark_remove.cjs`(Node · 주인 가르기 · 평면 항목 짓기는 진짜).
./venv/Scripts/python.exe -m pytest resources/tester/test_514_1_mark_remove.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_514_1_mark_remove.cjs")
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_알약_주인대로_지움(got) -> None:
assert got["owners"] == [
["pipe", 55], # 관 세션 id = 장부 자리
["pipe", 12.5], # 옛 자리 이름 관
["structure", "evac-1"],
["structure", "cross-2"],
]
def test_평면_마커_우클릭(got) -> None:
assert got["planMark"] == ["구조물 삭제"]
assert got["planPipe"] == ["구조물 삭제"] # 관도 같은 글
assert got["planEmpty"] == ["A군"]
assert got["calls"] == [["removeMark", "evac-1"], ["remove", "pipe-1"]]
def _read(*parts: str) -> str:
return (ROOT.joinpath(*parts)).read_text(encoding="utf-8")
def test_배선() -> None:
marks = _read("B05_Profile", "B05_Profile_UI_Structures_Marks.ts")
assert 'mark.addEventListener("contextmenu"' in marks
assert '["구조물 삭제", () => remove(id)]' in marks
render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts")
assert render.count("callbacks?.onStructureMarkRemove?.(structureId)") == 2 # 알약 · 평면
assert "배관 삭제" not in _read("B05_Profile", "B05_Profile_UI_Profile_Structures.ts")
assert 'toView(shared, "onStructureMarkRemove")' in _read(
"A00_Common", "design", "design_shared.ts"
)
view = _read("A00_Common", "design", "design_view_profile.ts")
assert "removeMarkOwner(structureId, removeOwner)" in view
cross = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts")
assert "removeMarkOwner(structureId, {" in cross