fix(B05 평면 구조물 마커): 마커 맨 위 · 다른 곳 누름 · Esc 고름 풂 · 오른쪽 판 구조물 · 관 측점 횡단 (409 · 411-A)

- 마커 그림 = 맨 마지막(교각점 · 손잡이 · 맴돔 위) · 누름 · 맴돔 · 커서 = 재기 다음 마커 먼저 · 마커 누름 = 고른 교각점 풂
- 마커 아닌 곳 누름 · Esc = 공용 고름을 b05-plan 으로 비움(오른쪽 횡단 판 그대로)
- 오른쪽 판 = 구조물 · 관 고름도 그 측점 횡단 · 묶음 뒤 열쇠로 붙듦(옮긴 뒤 새 자리)
- 시험 399 차례 고쳐 씀 · 새 409 · 411-A

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-18 09:07:37 +09:00
co-authored by Claude Opus 5.5
parent 0c2ce402f0
commit 24fe93ba6b
9 changed files with 401 additions and 34 deletions
@@ -36,10 +36,19 @@ global.window = {
clearTimeout: () => {},
clearInterval: () => {},
};
let prevented = 0;
const key = (type, name) =>
(listeners[type] || []).forEach((fn) =>
fn({ key: name, repeat: false, target: null, preventDefault() {}, stopPropagation() {} }),
fn({
key: name,
repeat: false,
target: null,
preventDefault: () => (prevented += 1),
stopPropagation() {},
}),
);
/** 공용 고름에 적은 것(409 풀기) — [값, 낸 쪽]. */
const written = [];
const STUBS = {
"../A00_Common/touch_bridge": {
@@ -50,7 +59,16 @@ const STUBS = {
"../A00_Common/input_prefs": { isTrackpadPinch: () => false },
"../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 16 },
"../A00_Common/design/design_store": { draftProjectId: () => "p1" },
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51, readSelection: () => selection },
"../A00_Common/design/design_selection": {
PIPE_MATCH_M: 0.51,
NO_SELECTION: { chainageM: null, structureId: null },
readSelection: () => selection,
selectDesign: (projectId, value, options) => {
written.push([value.chainageM, options.from]);
selection = value.chainageM === null ? null : value;
},
},
"./B05_Profile_UI_RouteEdit_Cross": { PLAN_PICK_FROM: "b05-plan" },
"../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true },
"./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 },
"./B05_Profile_Api_Structures": {
@@ -315,7 +333,24 @@ const was = redrawn;
marks.planMarksChanged("p1");
out.redrawnOnChange = redrawn - was;
// ⑥ 순수 셈
// ⑥ 고름 풀기(409) — 고른 것 없으면 아무 일 없음 · 있으면 공용 고름을 b05-plan 으로 비움 · Esc 도
selection = null;
out.deselectNone = [input.deselect(), written.length];
selection = { chainageM: 40, structureId: "A" };
out.deselectPicked = [input.deselect(), ...written.slice(-1), selection];
selection = { chainageM: 80.17, structureId: null }; // 관 고름(누가거리만)
prevented = 0;
key("keydown", "Escape");
out.escapePicked = [...written.slice(-1), selection, prevented];
const writes = written.length;
key("keydown", "Escape"); // 고른 것 없음 = 공용 Esc 몫(안 막음)
out.escapeNone = [written.length - writes, prevented];
// 마커 누름 = 고름(공용 고름 길) · 법선 · 창 — 그린 차례는 `_Render` 맨 끝(시험이 원본으로 봄)
input.press(80, 0);
input.release();
out.pressAfterRelease = selects[selects.length - 1];
// ⑦ 순수 셈
out.logic = {
round: [logic.markChainage(41.4, 200), logic.markChainage(41.6, 200)],
clamp: [logic.markChainage(-3, 200), logic.markChainage(250, 199.6)],
@@ -0,0 +1,159 @@
/* 411-A B05 평면 오른쪽 횡단 판 — 구조물 · 관을 골라도 그 측점 횡단 · 옮긴 뒤에도 열쇠로 따라감.
* `_RouteEdit_Cross` 를 Node 로 돌려 JSON 한 줄(`test_411_a_cross_structure.py` 가 판정). 묶음 · 그림 · 가게는 가짜 ·
* 고름 구독 · 측점 짚기 · 판 채우기는 진짜.
* node helper_411_a_cross_structure.cjs
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const B05 = path.join(__dirname, "..", "..", "B05_Profile");
const out = {};
/* 가짜 DOM — 판(제목 · 상자 · 발) 글만 봄 */
function fakeElement() {
const parts = new Map();
const element = {
textContent: "",
children: [],
isConnected: true,
clientHeight: 150,
setAttribute() {},
append: (...items) => element.children.push(...items),
replaceChildren: (...items) => (element.children = items),
getBoundingClientRect: () => ({ width: 240, height: 150 }),
querySelector: (selector) => {
if (!parts.has(selector)) parts.set(selector, fakeElement());
return parts.get(selector);
},
};
return element;
}
global.document = { createElement: fakeElement };
global.ResizeObserver = class {
observe() {}
disconnect() {}
};
let listener = null;
let stations = [];
const route = { id: 1 };
const subscribers = [];
const pipe = {
output: (stage) => (stage === "ground" ? { stations } : {}),
input: () => route,
subscribe: (fn) => (subscribers.push(fn), () => {}),
};
const crossStage = () => subscribers.forEach((fn) => fn({ kind: "stage", stage: "cross" }));
const STUBS = {
"../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan() {} },
"../A00_Common/design/design_pipeline_page": { pagePipeline: () => pipe },
"../A00_Common/design/design_selection": {
nearestWithin: (items, at, of) => {
let best;
let gap = 0.51;
for (const item of items)
if (Math.abs(of(item) - at) < gap) [best, gap] = [item, Math.abs(of(item) - at)];
return best;
},
selectDesign() {},
subscribeSelection: (_projectId, fn) => ((listener = fn), () => {}),
},
"../A00_Common/design/design_store": {
useDesignStore: () => ({ subscribe: () => () => {}, get: () => null }),
},
"./logic/B05_Profile_Pipeline_Detail": {
stationSection: (station) => ({ chainage_m: station.chainageM, samples: [] }),
},
"./B05_Profile_UI_RouteEdit_Cross_Draw": {
buildCrossSvg: () => ({ svg: fakeElement(), layer: null, width: 240, height: 150 }),
CROSS_PLOT_PAD: 0,
summarizeCross: (preview) => `횡단 ${preview.chainage_m}`,
},
"../A00_Common/b_page_state": { readStateRaw: () => null },
"../A00_Common/design/design_page_route": { pageRouteId: () => null },
"../common_util/common_util_station": { stationLabel: (chainageM) => String(chainageM) },
"./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new Map() },
"./B05_Profile_UI_RouteEdit_Cross_Card": {
routeCrossCard: () => fakeElement(),
routeCrossSource: () => null,
},
};
const cache = new Map();
function loadTs(full) {
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) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const cross = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Cross.ts"));
const station = (key, chainageM, kind, id = null) => ({ key, chainageM, kind, id });
const grid = [
station("end:BP", 0, "end"),
station("grid:1", 20, "grid"),
station("grid:2", 40, "grid"),
];
const side = fakeElement();
const preview = cross.createCrossPreview({
projectId: "p1",
side,
request: () => ({ vertices: [], min_radius_m: 15, station_interval_m: 20 }),
});
const [current] = side.children;
const shownNow = () => ({
station: current.querySelector(".b05-routeedit__cross-station").textContent,
foot: current.querySelector(".b05-routeedit__cross-foot").textContent,
});
const pick = (chainageM, structureId, from) => listener({ chainageM, structureId }, from);
// ① 구조물 고름(id) = 그 측점 횡단 · 법선은 마커 몫(structure)
stations = [
...grid,
station("structure:A", 45, "structure", "A"),
station("pipe:p1", 80.17, "pipe", "p1"),
];
pick(45, "A", "b05");
out.structurePick = { ...shownNow(), structure: preview.watched()?.structure ?? null };
// ② 옮김 — 묶음이 새 자리로(같은 열쇠) · cross 차례 = 새 자리 횡단(고름은 옛 45 그대로)
stations = [
...grid,
station("structure:A", 52, "structure", "A"),
station("pipe:p1", 80.17, "pipe", "p1"),
];
crossStage();
out.structureMoved = { ...shownNow(), at: preview.watched()?.chainageM ?? null };
// ③ 관 고름(누가거리만) = 그 측점 횡단
pick(80.17, null, "b05");
out.pipePick = { ...shownNow(), structure: preview.watched()?.structure ?? null };
// ④ 평면 마커 고름 풂(b05-plan) = 판 그대로
pick(null, null, "b05-plan");
out.planRelease = shownNow();
// ⑤ 일반측점 고름 = 그 측점 횡단 · 법선(structure 없음)
pick(20, null, "b05");
out.gridPick = { ...shownNow(), structure: preview.watched()?.structure ?? null };
// ⑥ 묶음 전 구조물 고름 — 열쇠 없이 기다렸다 묶음 뒤 열쇠로 붙듦 · 그 뒤 옮김도 따라감
stations = [];
pick(60, "B", "b05");
out.beforePipeline = shownNow();
stations = [...grid, station("structure:B", 60, "structure", "B")];
crossStage();
out.afterPipeline = shownNow();
stations = [...grid, station("structure:B", 66, "structure", "B")];
crossStage();
out.latchedMoved = shownNow();
// ⑦ 다른 보기가 풂(null · b05) = 판 비움(382-2 그대로)
pick(null, null, "b05");
out.otherRelease = { ...shownNow(), watched: preview.watched() };
console.log(JSON.stringify(out));
@@ -65,18 +65,23 @@ def test_공용_고름과_같은_길() -> None:
assert " onStructureSelect,\n onStructureMarkMove,\n" in view # 알약 콜백 = 같은 함수
assert "select: onStructureSelect," in view and "move: onStructureMarkMove," in view
render = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Render.ts")
labels = render.index("drawContourLabels(context, scene, band, style);\n drawStructureMarks")
labels = render.index("drawContourLabels(context, scene, band, style);")
nodes = render.index("scene.planned.forEach((vertex, index) => {")
assert labels < nodes # 라벨 위 · 교각점 아래
hover = render.index("drawPickHover(context, scene, line, style, NODE_R);")
marks = render.index("drawStructureMarks(context, scene, line, style);")
turned = render.index("context.restore(); // 회전 끝")
assert labels < nodes < hover < marks < turned # 맨 위(409 — 교각점 · 손잡이 · 맴돔 위)
def test_집기_차례는_교각점_손잡이_다음() -> None:
def test_집기_차례는_재기_다음_맨_앞() -> None:
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
measure = pointer.index("void params.measure.pick(px, py);")
mark = pointer.index("if (marks.press(px, py)) {")
release = pointer.index("marks.deselect();")
node = pointer.index("if (hitNode >= 0) {")
handle = pointer.index("} else if (dragHandle) {")
mark = pointer.index("} else if (marks.press(px, py)) {")
tick = pointer.index('if (!pick || !("chainage" in pick)) return false;')
assert node < handle < mark < tick
assert measure < mark < release < node < handle < tick # 409
def test_끌기_1m_정수_놓을_때_한_번(got) -> None:
@@ -0,0 +1,76 @@
"""409 B05 평면 구조물 마커 맨 위 · 다른 곳 누르면 고름 풀림(요구 252-4).
그림 = 맨 마지막(교각점 · 손잡이 · 맴돔 위) · 누름 · 맴돔 · 커서 = 재기 다음 마커 먼저 ·
마커 누르면 고른 교각점 풂 ·
마커 아닌 곳 누름 · Esc = 공용 고름을 `b05-plan` 으로 비움(오른쪽 횡단 판 그대로).
TS 셈은 `helper_399_plan_structure_marks.cjs`(⑥ 고름 풀기).
./venv/Scripts/python.exe -m pytest resources/tester/test_409_mark_top_deselect.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_399_plan_structure_marks.cjs")
def _read(*parts: str) -> str:
return (ROOT.joinpath(*parts)).read_text(encoding="utf-8")
@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["deselectNone"] == [False, 0]
def test_풀기_공용_고름을_b05_plan_으로_비움(got) -> None:
assert got["deselectPicked"] == [True, [None, "b05-plan"], None]
def test_Esc_마커_고름만_풂(got) -> None:
assert got["escapePicked"] == [[None, "b05-plan"], None, 1] # 관 고름도 · 공용 Esc 막음
assert got["escapeNone"] == [0, 1] # 고른 것 없음 = 공용 Esc 몫
def test_마커_누름은_그대로_고름(got) -> None:
assert got["pressAfterRelease"] == "A"
def test_누름_마커_먼저_교각점_풂() -> None:
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
down = pointer[pointer.index("if (marks.press(px, py)) {") :]
branch = down[: down.index("marks.deselect();")]
assert "params.setPicked(-1);" in branch and "params.syncCurveBar();" in branch
assert "canvas.setPointerCapture(event.pointerId);\n return;" in branch
assert "else if (marks.press(" not in pointer # 옛 차례(교각점 · 손잡이 다음) 걷힘
def test_맴돔_커서_마커_먼저() -> None:
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
over = pointer.index("const onMark = !measuring && marks.over(px, py);")
pick = pointer.index("const over = measuring || onMark ? null : pickAt(px, py);")
cursor = pointer.index("canvas.style.cursor = onMark")
assert over < pick < cursor
def test_오른쪽_판은_제_이름_알림을_거름() -> None:
cross = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts")
assert "if (from !== PLAN_PICK_FROM) follow(selection);" in cross
marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts")
assert "selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });" in marks
@@ -0,0 +1,58 @@
"""411-A B05 평면 오른쪽 횡단 판 — 구조물 · 관을 골라도 그 측점 횡단(요구 252-6) ·
옮긴 뒤에도 그 측점.
TS 셈은 `helper_411_a_cross_structure.cjs`(Node · `_RouteEdit_Cross` 진짜 · 묶음 · 그림 가짜).
./venv/Scripts/python.exe -m pytest resources/tester/test_411_a_cross_structure.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
HELPER = Path(__file__).with_name("helper_411_a_cross_structure.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["structurePick"] == {"station": "45", "foot": "횡단 45", "structure": True}
def test_옮긴_뒤_열쇠로_새_자리(got) -> None:
assert got["structureMoved"] == {"station": "52", "foot": "횡단 52", "at": 52}
def test_관_고름_그_측점_횡단(got) -> None:
assert got["pipePick"] == {"station": "80.17", "foot": "횡단 80.17", "structure": True}
def test_평면_마커_풂은_판_그대로(got) -> None:
assert got["planRelease"] == {"station": "80.17", "foot": "횡단 80.17"}
def test_일반측점_그대로(got) -> None:
assert got["gridPick"] == {"station": "20", "foot": "횡단 20", "structure": None}
def test_묶음_전_고름도_열쇠로_붙듦(got) -> None:
assert got["beforePipeline"]["foot"] == "계산 중…"
assert got["afterPipeline"] == {"station": "60", "foot": "횡단 60"}
assert got["latchedMoved"] == {"station": "66", "foot": "횡단 66"}
def test_다른_보기_풂은_판_비움(got) -> None:
assert got["otherRelease"]["station"] == "" and got["otherRelease"]["watched"] is None