diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 5e5d7d6d1..a10dba7c8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -447,10 +447,11 @@ export async function mountRouteEditor( historyControls.sync(); } - /** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430). */ + /** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430 · 433-14 ②). */ + const otherChanged = (): boolean => pickedTerrain() !== designTerrain || snap.changed(); function tellDraft(): void { if (!history) return; - const dirty = lineChanged(history) || pickedTerrain() !== designTerrain || snap.changed(); + const dirty = lineChanged(history) || otherChanged(); options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null); } @@ -587,8 +588,7 @@ export async function mountRouteEditor( projectId, nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }), // 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2). - changed: () => - (!!history && lineChanged(history)) || pickedTerrain() !== designTerrain || snap.changed(), + changed: () => (!!history && lineChanged(history)) || otherChanged(), terrain: pickedTerrain, snap: snap.draft, initial, @@ -627,7 +627,7 @@ export async function mountRouteEditor( // 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로. initial.ready(snapshotNow(), plan.current_initial ?? null); history = createRouteEditHistory(snapshotNow()); - history = draftLink.watch(history, options.draft, snap.draft); + history = draftLink.watch(history, options.draft, snap.draft, otherChanged); draftLink.restore(options.draft, restoreSnapshot); historyControls.sync(); syncCurveBar(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts index 6db526b86..32b8e7bef 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts @@ -58,15 +58,18 @@ export function snapshotOfDraft(draft: RouteDraft): RouteEditSnapshot { }; } -/** 사진첩을 감싸 노선이 바뀔 때마다 다리에 알림 — 쌓기 · 되돌리기 · 다시하기 · 열 때로. */ +/** 사진첩을 감싸 노선이 바뀔 때마다 다리에 알림 — 쌓기 · 되돌리기 · 다시하기 · 열 때로. + * `changed` = 노선 밖 바뀜(스냅 · 분석 지형) — 노선이 처음으로 돌아가도 이것이면 초안(433-14 ②). */ export function watch( history: RouteEditHistory, bridge: RouteDraftBridge | undefined, snap: () => SnapSetting, + changed: () => boolean = () => false, ): RouteEditHistory { if (!bridge) return history; const tell = (snapshot: RouteEditSnapshot | null): RouteEditSnapshot | null => { - if (snapshot) bridge.edited(lineChanged(history) ? draftOfSnapshot(snapshot, snap()) : null); + if (snapshot) + bridge.edited(lineChanged(history) || changed() ? draftOfSnapshot(snapshot, snap()) : null); return snapshot; }; /** 사진첩이 되쓴 노선 초안 → 편집기 사진(같은 선의 옛 걸음이 있으면 고정 · 꼬리표째). */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index 2cd6d527a..8966c080f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -195,8 +195,8 @@ export function handleAtScreen( px: number, py: number, hitPx: number, -): { node: number; end: "start" | "end" } | null { - let best: { node: number; end: "start" | "end" } | null = null; +): { node: number; end: "start" | "end"; distance: number } | null { + let best: { node: number; end: "start" | "end"; distance: number } | null = null; let bestDistance = hitPx; curves.forEach((curve) => { (["start", "end"] as const).forEach((which) => { @@ -205,7 +205,7 @@ export function handleAtScreen( const distance = Math.hypot(x - px, y - py); if (distance <= bestDistance) { bestDistance = distance; - best = { node: curve.node_first, end: which }; + best = { node: curve.node_first, end: which, distance }; } }); }); @@ -382,6 +382,20 @@ export function pickAtScreen( return node >= 0 ? { node } : null; } +/** 누름 · 맴돔에 이길 것(433-14 ③ · 433-21 ③) — 노드 가운데 `corePx` 안 = 노드(`pick`) · 밖 = 마커 · + * 노드 · 손잡이 중 가까운 것(같으면 그 차례) · 셋 다 없으면 `pick`(눈금 · 빈 곳). 거리 = 화면 px · 없으면 Infinity. */ +export function pressWinner( + nodeDistance: number, + corePx: number, + markDistance: number, + handleDistance: number, +): "mark" | "pick" | "handle" { + if (nodeDistance <= corePx) return "pick"; + const best = Math.min(markDistance, nodeDistance, handleDistance); + if (best === Infinity) return "pick"; + return markDistance === best ? "mark" : nodeDistance === best ? "pick" : "handle"; +} + /** 맴도는 자리(381-3) — `Pointer` 가 적고 `Render` 가 고리 · 파랑으로 밝힘. 창(캔버스)마다 따로. */ const hovers = new WeakMap(); export const setPickHover = (canvas: HTMLCanvasElement, pick: RouteEditPick): void => diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 73d635759..38013b006 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -25,6 +25,7 @@ import { nodeAtScreen, pickAtScreen, pickHover, + pressWinner, segmentAtScreen, setPickHover, stationAtScreen, @@ -42,7 +43,7 @@ import { /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기(381-3 — 9 → 12). */ const NODE_HIT_PX = 12; -/** 노드 가운데 이 안(px)은 횡단 눈금이 가까워도 노드(381-3). */ +/** 노드 가운데 이 안(px)은 횡단 눈금 · 손잡이 · 구조물 마커가 가까워도 노드(381-3 · 433-14 ③ · 433-21 ③). */ const NODE_CORE_PX = 6; /** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */ const SEGMENT_HIT_PX = 12; @@ -121,7 +122,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const { canvas, status, toScreen, toMetric } = params; /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ - const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => { + const handleAt = ( + px: number, + py: number, + ): { node: number; end: "start" | "end"; distance: number } | null => { const { curveInfo, curveLock, apexLock } = params.state(); return handleAtScreen( // 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5). @@ -199,6 +203,23 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP let dragOff = 0; /** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */ const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); + /** 누름 · 맴돔 한 판정 — 노드 가운데 6 px = 노드 · 밖 = 마커 · 노드 · 손잡이 중 가까운 것(`pressWinner`). */ + const chooseAt = (px: number, py: number) => { + const pick = pickAt(px, py); + const node = pick && "node" in pick ? toScreen(params.state().planned[pick.node]) : null; + const handle = handleAt(px, py); + const won = pressWinner( + node ? Math.hypot(node[0] - px, node[1] - py) : Infinity, + NODE_CORE_PX, + marks.distance(px, py), + handle?.distance ?? Infinity, + ); + return { + mark: won === "mark", + pick: won === "pick" ? pick : null, + handle: won === "handle" ? handle : null, + }; + }; canvas.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; @@ -211,8 +232,9 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP } const state = params.state(); hover(null); // 누르는 동안 · 끄는 동안 맴돔 끔(381-3) - // 구조물 마커가 먼저(409) — 교각점 · 손잡이와 겹쳐도 마커 · 고른 교각점은 풂(마커 방향키 몫) - if (marks.press(px, py)) { + // 구조물 마커 — 교각점 가운데 밖이면 교각점 · 손잡이보다 가까울 때(433-21 ③) · 고른 교각점은 풂(마커 방향키 몫) + const choice = chooseAt(px, py); + if (choice.mark && marks.press(px, py)) { if (state.picked >= 0) { params.setPicked(-1); params.syncCurveBar(); @@ -222,17 +244,15 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP return; } marks.deselect(); // 마커 아닌 곳 = 마커 고름 풂(오른쪽 횡단 판은 그대로) - const pick = pickAt(px, py); - // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 - // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 - // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. - // 노드 가운데(6px)는 노드 · 그 밖은 횡단 눈금과 가까운 쪽(381-3 `pickAt`). + const pick = choice.pick; + // 노드 가운데(6px)는 노드 — 헤어핀처럼 곡선이 몰린 데서도 노드를 집음(2026-09-07 사용자 지적 ④) · + // 그 밖은 횡단 눈금 · 손잡이와 가까운 쪽(381-3 `pickAt` · 433-14 ③ 낮은 확대 R 12 손잡이). const hitNode = pick && "node" in pick ? pick.node : -1; // 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①). dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1; dragRaw = null; dragOff = params.offStation(); - dragHandle = hitNode >= 0 ? null : handleAt(px, py); + dragHandle = choice.handle; dragMoved = false; if (hitNode >= 0) { params.setPicked(hitNode); // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. @@ -298,10 +318,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP canvas.setPointerCapture(event.pointerId); }); - /** 스냅 미리보기 — 맞추기 전 자리를 쥔 뒤 맞춤(끔이면 쥐지 않음). */ - const fitPreview = (planned: Vertex[]): void => { + /** 스냅 미리보기 — 맞추기 전 자리를 쥔 뒤 교각점 `index` 자리 그대로 맞춤(끔이면 쥐지 않음). */ + const fitPreview = (planned: Vertex[], index = dragNode): void => { const raw = planned.map((vertex): Vertex => [vertex[0], vertex[1]]); - dragRaw = params.fitDrag(dragNode) ? raw : null; + dragRaw = params.fitDrag(index) ? raw : null; }; canvas.addEventListener("pointermove", (event) => { @@ -311,42 +331,57 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP if (marks.move(px, py)) return; if (dragHandle) { // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). + // 노드 갈래와 같게(433-21 ①) — 맞추기 전 자리에서 셈 · 미리보기 맞춤 · 측점 밖이 늘면 그 걸음 되돌림. const node = dragHandle.node; - const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); - if (moved) { - // 교각점이 움직여 이웃 곡선 접선 자리도 바뀜 — 지키던 하한을 새로 깨면 그 걸음만 되돌림(430). - const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM); - const kept = [state.planned[node], state.curveRadius[node], state.curveOn[node]] as const; - state.planned[node] = moved.apex; - // 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④). - // 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다. - state.curveRadius[node] = Math.max( - radiusFloorM(state.nodeInfo[node]?.inner_angle_deg, state.limitRadiusM, state.limitArcM), - Math.round(moved.radius * 100) / 100, - ); - state.curveOn[node] = true; - params.setPicked(node); - // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 - // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + if (dragRaw) { + dragRaw.forEach((vertex, index) => (state.planned[index] = vertex)); params.markEdited(); - const after = params.state(); - if ( - shortfallCrossed( - before, - curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM), - ) - ) { - [state.planned[node], state.curveRadius[node], state.curveOn[node]] = kept; - params.markEdited(); - status.textContent = `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다${floorNote(state.limitRadiusM, state.limitArcM)}.`; - params.draw(); - return; - } - dragMoved = true; - params.syncCurveBar(); - status.textContent = `${params.routeHead()} — 곡선을 잡는 중. ${params.curveHint()}`; - params.draw(); } + const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); + if (!moved) { + if (dragRaw) fitPreview(params.state().planned, node); + return; + } + // 교각점이 움직여 이웃 곡선 접선 자리도 바뀜 — 지키던 하한을 새로 깨면 그 걸음만 되돌림(430). + const now = params.state(); + const before = curveShortfalls(now.nodeInfo, now.limitRadiusM, now.limitArcM); + const kept = [now.planned[node], now.curveRadius[node], now.curveOn[node]] as const; + const back = (message: string): void => { + const undo = params.state(); + dragRaw?.forEach((vertex, index) => (undo.planned[index] = vertex)); + [undo.planned[node], undo.curveRadius[node], undo.curveOn[node]] = kept; + params.markEdited(); + fitPreview(undo.planned, node); + status.textContent = `${params.routeHead()} — ${message}`; + params.draw(); + }; + now.planned[node] = moved.apex; + // 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④). + // 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다. + now.curveRadius[node] = Math.max( + radiusFloorM(now.nodeInfo[node]?.inner_angle_deg, now.limitRadiusM, now.limitArcM), + Math.round(moved.radius * 100) / 100, + ); + now.curveOn[node] = true; + params.setPicked(node); + // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 + // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + params.markEdited(); + const after = params.state(); + if ( + shortfallCrossed( + before, + curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM), + ) + ) + return back(`하한에 걸려 더 못 옮깁니다${floorNote(after.limitRadiusM, after.limitArcM)}.`); + fitPreview(after.planned, node); + if (dragRaw && params.offStation() > dragOff) + return back("곡선 가운데를 측점에 못 맞춰 더 못 옮깁니다."); + dragMoved = true; + params.syncCurveBar(); + status.textContent = `${params.routeHead()} — 곡선을 잡는 중. ${params.curveHint()}`; + params.draw(); return; } if (dragNode >= 0) { @@ -396,10 +431,11 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP // 재는 중 — 둘째 점 미리보기는 첫 점 노선 위에만 붙는다. const measuring = event.shiftKey || params.measureMode(); if (measuring && params.measure.preview(px, py)) params.draw(); - // 맴돌 때 이길 쪽만 밝힘(381-3) — 누르면 집힐 것과 같은 셈 · 재는 중엔 끔. - // 마커 위면 마커 몫(409) — 밑에 깔린 교각점 · 눈금은 안 밝힘. - const onMark = !measuring && marks.over(px, py); - const over = measuring || onMark ? null : pickAt(px, py); + // 맴돌 때 이길 쪽만 밝힘(381-3) — 누르면 집힐 것과 같은 셈(`chooseAt` · 433-21 ③) · 재는 중엔 끔. + // 마커 · 손잡이가 이기면 밑에 깔린 교각점 · 눈금은 안 밝힘. + const choice = measuring ? null : chooseAt(px, py); + const onMark = choice?.mark === true; + const over = choice?.pick ?? null; hover(over); const overNode = over && "node" in over ? over.node : -1; // 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다. @@ -409,7 +445,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP ? state.apexLock[overNode] ? "not-allowed" : "grab" - : handleAt(px, py) + : choice?.handle ? "grab" : over ? "pointer" @@ -422,6 +458,13 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const endDrag = (event: PointerEvent): void => { if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); marks.release(); + if (!dragMoved && dragRaw) { + // 막힌 걸음만 하고 놓음 — 맞추지 않은 기준이면 미리보기 맞춤이 남으므로 누르기 전 자리로(433-14 ①) + const now = params.state().planned; + dragRaw.forEach((vertex, index) => (now[index] = vertex)); + params.markEdited(); + params.draw(); + } // 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번 // 눌러야 한다. 놓는 순간에만 쌓는다. if (dragMoved) { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts index 5d7891c21..e93283042 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts @@ -264,8 +264,8 @@ export interface StructureMarkInput { move: (px: number, py: number) => boolean; /** 끄는 중이었으면 놓음(옮겼으면 한 번 옮김) · true. */ release: () => boolean; - /** 마커 위인가 — 커서 몫. */ - over: (px: number, py: number) => boolean; + /** 가장 가까운 마커까지 화면 거리(px) — 없으면 Infinity(누름 · 맴돔 · 커서가 노드 · 손잡이와 견줌 433-21 ③). */ + distance: (px: number, py: number) => number; /** 이 평면 마커가 고른 것이면 풂 · true(409 — 마커 아닌 곳 누름 · 다른 화면이 고른 것은 그대로 · 440). */ deselect: () => boolean; } @@ -287,7 +287,7 @@ export function bindStructureMarks( const state = params.state(); return state.plannedLine.length ? state.plannedLine : state.planned; }; - const markAt = (px: number, py: number): PlanMark | null => { + const markAt = (px: number, py: number): { mark: PlanMark; distance: number } | null => { const found = planMarks(canvas); const line = lineNow(); let best: PlanMark | null = null; @@ -299,7 +299,7 @@ export function bindStructureMarks( const distance = Math.hypot(x - px, y - py); if (distance <= bestDistance) [best, bestDistance] = [mark, distance]; } - return best; + return best ? { mark: best, distance: bestDistance } : null; }; const say = (moving: Moving, done: boolean): void => { const interval = sources.get(draftProjectId())?.intervalM() ?? params.stationIntervalM; @@ -401,7 +401,7 @@ export function bindStructureMarks( return { press(px, py) { - const mark = markAt(px, py); + const mark = markAt(px, py)?.mark; if (!mark) return false; release(); planMarks(canvas)?.source.select(mark.id); // 알약 고름과 같은 길(공용 고름) @@ -425,7 +425,7 @@ export function bindStructureMarks( return true; }, release: () => !holdKey && finish(), - over: (px, py) => markAt(px, py) !== null, + distance: (px, py) => markAt(px, py)?.distance ?? Infinity, // 빈 곳 누름 = 이 마커가 고른 것만 풂 — B06 카드 · 목록 · 알약이 고른 것은 그대로(440) · Esc 는 앞 그대로 deselect: () => planMarks(canvas)?.picked === ownPick && deselect(), }; diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index e8ab6d5ba..3679db75d 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -404,7 +404,7 @@ async function run() { marked: (dragRows ?? []).filter((row) => row[2] === 999).length, }; - // ⑧ 중심점 좌우(356-3 꼴) — 노선 한 걸음 → 새 종단이 오면 계획고 맞춤 · 첫 차례는 cross 전에 밀림(무거운 셈 한 번) + // ⑧ 노선 한 걸음 + 계획고 맞춤 — 새 종단이 오면 계획고 맞춤 · 첫 차례는 cross 전에 밀림(무거운 셈 한 번) let graded = false; const stopShift = qPipe.subscribe((event) => { if (event.kind !== "stage" || event.stage !== "profile" || graded) return; @@ -413,7 +413,7 @@ async function run() { void Promise.resolve().then(() => pageMod.pipelineGrade(Q, { station_offsets: { [key]: 0.1 }, curve_radii: {} }), ); - }, "중심점 좌우"); + }, "노선 한 걸음 + 계획고 맞춤"); mark = runs().length; qBridge.edited(draftOf(shift(1, 0, 1))); await settle(mark + 2); diff --git a/resources/tester/helper_390_5_snap_step_ops.cjs b/resources/tester/helper_390_5_snap_step_ops.cjs index da8e57ca5..27b338446 100644 --- a/resources/tester/helper_390_5_snap_step_ops.cjs +++ b/resources/tester/helper_390_5_snap_step_ops.cjs @@ -204,6 +204,110 @@ function sweep(fixedR) { } const swept = { auto: sweep(false), fixed: sweep(true) }; +// ④-2 손잡이(BC · EC) 끌기 훑기(433-21 ①) — `_Pointer` 손잡이 갈래 그대로: 맞추기 전 자리에서 셈 · 하한 막힘 · +// 미리보기 측점 밖이 누를 때보다 늘면 그 걸음 되돌림(`guard`) · 놓을 때 정본 맞춤. IP 셋 × 두 끝 × 12 방향 × 3 거리. +function sweepHandle(fixedR, guard = true) { + const start = columns(fit.fitCurveStations(columns(ROUTE), LIMITS, 1)); + if (fixedR) + start.curveRadius = edits + .rebuildLine(copy(start), LIMITS) + .nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100)); + const floor = (built) => + edits.curveShortfalls(built.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM); + const off = (built) => fit.offStationCount(built.vertices, built.curves, INTERVAL); + const tally = { drags: 0, released: 0, offAfter: 0, strayed: 0, worst: [] }; + for (const K of [2, 3, 4]) + for (const which of ["start", "end"]) + for (let deg = 0; deg < 360; deg += 30) + for (const reach of [9, 17, 26]) { + const hist = copy(start); + let st = { ...copy(hist), curveOn: [...hist.curveOn] }; + let built = edits.rebuildLine(st, LIMITS); + const offBefore = off(built); + const c0 = built.curves.find((c) => c.node_first === K); + if (!c0) continue; + const h0 = which === "start" ? c0.start : c0.end; + let raw = null; + let movedOnce = false; + const preview = () => { + raw = st.planned.map(([x, y]) => [x, y]); + st = { ...st, planned: fit.fitDrag(st, hist.planned, K, LIMITS) }; + built = edits.rebuildLine(st, LIMITS); + }; + for (let t = 0.5; t <= reach + 1e-9; t += 0.5) { + if (raw) raw.forEach((v, i) => (st.planned[i] = v)); + built = edits.rebuildLine(st, LIMITS); + const c = built.curves.find((entry) => entry.node_first === K); + const to = [ + h0[0] + Math.cos((deg * Math.PI) / 180) * t, + h0[1] + Math.sin((deg * Math.PI) / 180) * t, + ]; + const moved = c + ? curve.dragHandleTo( + st.planned[K - 1], + [c.apex[0], c.apex[1]], + st.planned[K + 1], + which, + to, + ) + : null; + if (!moved) { + if (raw) preview(); + continue; + } + const before = floor(built); + const kept = [st.planned[K], st.curveRadius[K], st.curveOn[K]]; + const back = () => { + raw?.forEach((v, i) => (st.planned[i] = v)); + [st.planned[K], st.curveRadius[K], st.curveOn[K]] = kept; + built = edits.rebuildLine(st, LIMITS); + preview(); + }; + st.planned[K] = moved.apex; + st.curveRadius[K] = Math.max( + edits.radiusFloorM( + built.nodes[K]?.inner_angle_deg, + LIMITS.limitRadiusM, + LIMITS.limitArcM, + ), + Math.round(moved.radius * 100) / 100, + ); + st.curveOn[K] = true; + built = edits.rebuildLine(st, LIMITS); + if (edits.shortfallCrossed(before, floor(built))) { + back(); + continue; + } + preview(); + if (guard && off(built) > offBefore) { + back(); + tally.strayed += 1; + continue; + } + movedOnce = true; + } + tally.drags += 1; + if (!movedOnce) continue; + built = edits.rebuildLine(st, LIMITS); + if (edits.floorStop(hist, st.planned, built.nodes, LIMITS)) continue; + const from = fit.fitStart(hist, st); + st = { ...st, planned: fit.fitCurveStations(st, LIMITS, from) }; + built = edits.rebuildLine(st, LIMITS); + tally.released += 1; + const left = fit.offStationMcs(built.vertices, built.curves, INTERVAL); + if (left.length) { + tally.offAfter += 1; + if (tally.worst.length < 5) tally.worst.push({ K, which, deg, reach, mc: left }); + } + } + return tally; +} +const sweptHandle = { + auto: sweepHandle(false), + fixed: sweepHandle(true), + unguarded: { auto: sweepHandle(false, false), fixed: sweepHandle(true, false) }, +}; + // ③ MC 측점 값 · 눈금 글 const input = JSON.parse(process.argv[2] || '{"grid":[],"spans":[]}'); const snapped = Object.fromEntries( @@ -227,6 +331,7 @@ console.log( keys, drag, swept, + sweptHandle, snapped, labels, scale: [0.1, 1, 10, null].map(frame.mcScale), diff --git a/resources/tester/helper_453_plan_pick_draft.cjs b/resources/tester/helper_453_plan_pick_draft.cjs new file mode 100644 index 000000000..c19499898 --- /dev/null +++ b/resources/tester/helper_453_plan_pick_draft.cjs @@ -0,0 +1,158 @@ +/* 453 평면 집기 · 스냅만 바꾼 초안 헬퍼 — 진짜 `_RouteEdit_Input`(pressWinner · handleAtScreen) · + * `_RouteEdit_History` · `_RouteEdit_Draft`(다리 감싸기)를 트랜스파일해 돌림. 결과 JSON 한 벌(표준출력). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const data = new Map(); +globalThis.sessionStorage = { + get length() { + return data.size; + }, + key: (i) => [...data.keys()][i] ?? null, + getItem: (k) => (data.has(k) ? data.get(k) : null), + setItem: (k, v) => data.set(k, String(v)), + removeItem: (k) => data.delete(k), +}; +const win = new EventTarget(); +win.setTimeout = setTimeout; +win.clearTimeout = clearTimeout; +win.sessionStorage = globalThis.sessionStorage; +win.localStorage = { getItem: () => null }; +globalThis.window = win; + +const STUBS = { + "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback }, + "@util/common_util_svg": { stationLabel: (c, i) => `No.${Math.round(c / i)}` }, +}; +const cache = new Map(); +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) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const page = path.join(ROOT, "B05_Profile"); +const input = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_Input.ts")); +const edit = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_History.ts")); +const draftLink = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_Draft.ts")); + +// ① 누름 판정 — `_Pointer.chooseAt` 와 같은 셈(노드 · 손잡이는 화면 8 px 떨어짐 · 433-14 ③ 낮은 확대 R 12) +const toScreen = ([x, y]) => [x, y]; +const node = [100, 100]; +const curves = [{ node_first: 1, start: [108, 100], end: [100, 108] }]; +const choose = (px, py, markDistance = Infinity) => { + const pick = input.pickAtScreen([[0, 0], node, [200, 200]], toScreen, px, py, 12, 6, null); + const at = pick && "node" in pick ? toScreen(node) : null; + const handle = input.handleAtScreen(curves, toScreen, px, py, 14); + return input.pressWinner( + at ? Math.hypot(at[0] - px, at[1] - py) : Infinity, + 6, + markDistance, + handle?.distance ?? Infinity, + ); +}; +const pick = { + handleSide: choose(107, 100), // 노드 7 px(가운데 밖) · 손잡이 1 px — 손잡이 + handleCenter: choose(108, 100), // 손잡이 위 · 노드 8 px — 손잡이 + coreEdge: choose(106, 100), // 노드 6 px(가운데 끝) · 손잡이 2 px — 노드 + nodeCore: choose(102, 100), // 노드 2 px(가운데 안) — 노드 + nodeCloser: choose(93, 100), // 노드 7 px · 손잡이 15 px(문턱 밖) — 노드 + handleOnly: choose(116, 100), // 노드 16 px(문턱 밖) · 손잡이 8 px — 손잡이 + nothing: choose(150, 150), // 빈 곳 — pick(null 그대로) + markOnCore: choose(103, 100, 1), // 마커 1 px 이어도 노드 가운데 3 px — 노드(433-21 ③) + markCloser: choose(93, 100, 3), // 노드 7 px · 마커 3 px — 마커 + nodeBeatsMark: choose(93, 100, 9), // 노드 7 px · 마커 9 px — 노드 + tie: input.pressWinner(8, 6, 8, 8), // 같으면 마커 → 노드 → 손잡이 + handleDistance: input.handleAtScreen(curves, toScreen, 111, 100, 14)?.distance ?? null, +}; + +// ② 스냅만 바꾼 뒤 노선 처음으로 — 다리 초안(433-14 ②) +const snap = (x) => ({ + planned: [ + [0, 0], + [x, 0], + [100, 0], + ], + curveOn: [true, true, true], + curveRadius: [null, null, null], + curveLock: [null, null, null], + curveArc: [null, null, null], + apexLock: [false, false, false], + picked: -1, +}); +const draft = (snapChanged) => { + const told = []; + const bridge = { edited: (d) => told.push(d ? d.nodes[1][0] : null), saved: () => null }; + const history = draftLink.watch( + edit.createRouteEditHistory(snap(50)), + bridge, + () => ({ stationSnap: true, snapStepM: 1 }), + () => snapChanged, + ); + history.commit(snap(60)); + history.undo(); + history.redo(); + history.undo(); + return told; +}; +const old = (() => { + const told = []; + const bridge = { edited: (d) => told.push(d ? d.nodes[1][0] : null), saved: () => null }; + const history = draftLink.watch(edit.createRouteEditHistory(snap(50)), bridge, () => ({})); + history.commit(snap(60)); + history.undo(); + return told; +})(); + +// ③ 맞추지 않은 기준에서 막힌 걸음만 하고 놓음(433-14 ① · req269 idem 재현) — 막힌 걸음 미리보기 +// (`fitPreview` = `fitDrag`)가 노선을 바꿈 → `endDrag` 가 맞추기 전 자리(`dragRaw`)로 되돌림. +const fit = loadTs(path.join(page, "logic", "B05_Profile_RouteEdit_StationFit.ts")); +const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: 20 }; +const ROUTE = [[0, 0]]; +{ + let seed = 1; + const rnd = () => ((seed = (seed * 16807) % 2147483647), seed / 2147483647); + let a = 0; + for (let i = 0; i < 14; i += 1) { + a += (rnd() - 0.5) * 1.6; + const L = 35 + rnd() * 60; + const q = ROUTE[ROUTE.length - 1]; + ROUTE.push([q[0] + Math.cos(a) * L, q[1] + Math.sin(a) * L]); + } +} +const cols = (planned) => ({ + planned, + curveOn: planned.map(() => true), + curveRadius: planned.map(() => null), + curveLock: planned.map(() => null), + curveArc: planned.map(() => null), + apexLock: planned.map((_, i) => i === 0 || i === planned.length - 1), +}); +const gap = (a, b) => Math.max(...a.map((v, i) => Math.hypot(v[0] - b[i][0], v[1] - b[i][1]))); +const blocked = { drags: 0, previewMoved: 0, leftAfterRelease: 0 }; +for (let K = 1; K < ROUTE.length - 1; K += 1) { + const base = ROUTE.map(([x, y]) => [x, y]); + const dragRaw = base.map(([x, y]) => [x, y]); // 막힌 걸음 = 누르기 전 자리 그대로 맞춤 + let planned = fit.fitDrag(cols(base), base, K, LIMITS); + blocked.drags += 1; + if (gap(planned, base) > 1e-6) blocked.previewMoved += 1; + dragRaw.forEach((vertex, index) => (planned[index] = vertex)); // endDrag(!dragMoved && dragRaw) + if (gap(planned, base) > 1e-6) blocked.leftAfterRelease += 1; +} + +process.stdout.write( + JSON.stringify({ pick, blocked, draft: { snapChanged: draft(true), same: draft(false), old } }), +); diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py index 861b0e8ec..c99117c11 100644 --- a/resources/tester/test_365_page_pipeline.py +++ b/resources/tester/test_365_page_pipeline.py @@ -12,7 +12,7 @@ - B05 오른쪽 횡단 판 = 묶음 그 측점 — 계획고 · 절토 면적 · 표본 = B06 카드 · 서버 길 없음. 못박는 것(365-4) - 저장 노선 ▲▼ 한 번 = 횡단 한 벌 — 묶음 cross 가 다시 셈 창구(상세 설계) 한 번 · 종단도 예약 끔. - - 중심점 좌우 = 노선 차례는 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례만). + - 노선 한 걸음 + 계획고 맞춤 = 노선 차례는 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례만). - B06 다시 그림 = 겹친 신호(갈아끼움 · 묶음 cross · 초안) 한 번 · profile 차례는 안 그림. 못박는 것(365-5) - 들어온 화면 무관 — 설계 칸 = 세션 저장 상세 · 초안 자리 시설 = 차례 짝 · 제원 지문 · @@ -147,7 +147,7 @@ def test_365_4_저장_노선_계획고_횡단_한_벌(r): assert s["fromDetail"] # 묶음 cross = 상세 설계 그대로(엔진 따로 안 셈) -def test_365_4_중심점_좌우_횡단_한_번(r): +def test_365_4_노선_한_걸음_계획고_맞춤_횡단_한_번(r): runs = r["shiftRuns"] assert [x["outcome"] for x in runs] == ["dropped", "done"], runs assert [x["cross"] for x in runs] == [False, True] diff --git a/resources/tester/test_381_3_routeedit_node_pick.py b/resources/tester/test_381_3_routeedit_node_pick.py index f4f56f738..edc6a58f0 100644 --- a/resources/tester/test_381_3_routeedit_node_pick.py +++ b/resources/tester/test_381_3_routeedit_node_pick.py @@ -45,12 +45,12 @@ def test_집기_맴돔_한_함수(): assert "pickAtScreen(" in pointer down = pointer[pointer.index('addEventListener("pointerdown"') :] down = down[: down.index('addEventListener("pointermove"')] - assert "hover(null);" in down and "const pick = pickAt(px, py);" in down + assert "hover(null);" in down and "const pick = choice.pick;" in down # chooseAt 안 pickAt(433) assert "nodeAt(px, py)" not in down and "stationAtScreen(" not in down move = pointer[pointer.index('addEventListener("pointermove"') :] move = move[: move.index("const endDrag")] # 끄는 동안은 앞에서 돌아감 — 맴돔은 끌기 갈래 뒤 - assert move.index("if (dragNode >= 0)") < move.index("pickAt(px, py)") + assert move.index("if (dragNode >= 0)") < move.index("chooseAt(px, py)") assert "hover(over);" in move assert 'addEventListener("pointerleave"' in pointer diff --git a/resources/tester/test_390_5_snap_step_ops.py b/resources/tester/test_390_5_snap_step_ops.py index e1813cbbe..57a16ab2e 100644 --- a/resources/tester/test_390_5_snap_step_ops.py +++ b/resources/tester/test_390_5_snap_step_ops.py @@ -93,6 +93,16 @@ def test_끌기_훑기_놓은_뒤_측점_밖_0(got, mode) -> None: assert swept["strayed"] == 0 +@pytest.mark.parametrize("mode", ["auto", "fixed"]) +def test_손잡이_끌기_훑기_놓은_뒤_측점_밖_0(got, mode) -> None: + """433-21 ① — 손잡이(BC · EC) 끌기도 노드 갈래와 같게: 미리보기 측점 밖이 늘면 그 걸음 되돌림 → + 놓은 뒤 MC 가 다 측점 위(되돌림 없으면 216 중 20 남음 = 기준이 고침을 잼).""" + swept = got["sweptHandle"][mode] + assert swept["drags"] == 216 and swept["released"] > 0 + assert swept["offAfter"] == 0, swept["worst"] + assert got["sweptHandle"]["unguarded"][mode]["offAfter"] > 0 + + @pytest.mark.parametrize("step", ["0.1", "1", "10", "20"]) def test_미리보기는_쌓이지_않고_놓을_때와_같음(got, step) -> None: drag = got["drag"][step] @@ -105,7 +115,7 @@ def test_끌기_방향키_편집기_이음() -> None: pointer = (B05 / "B05_Profile_UI_RouteEdit_Pointer.ts").read_text(encoding="utf-8") keys = (B05 / "B05_Profile_UI_RouteEdit_Keys.ts").read_text(encoding="utf-8") edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") - assert "params.fitDrag(dragNode)" in pointer + assert "params.fitDrag(index)" in pointer and "fitPreview(after.planned, node);" in pointer assert "params.fitDrag(now.index)" in keys and "params.snapStepM()" in keys assert "fit.fitDrag(snapshotNow(), base, index, fitLimits())" in edit # 하한 검사는 맞추기 전 노선으로(맞춤이 IP 를 ±간격/2 밀어 10 m · 측점 No. 에서 헛걸림) @@ -116,6 +126,12 @@ def test_끌기_방향키_편집기_이음() -> None: move = pointer[pointer.index("const previous = state.planned[dragNode];") :] assert move.index("shortfallCrossed(") < move.index("params.offStation() > dragOff") assert move.index("params.offStation() > dragOff") < move.index("dragMoved = true;") + # 손잡이 갈래도 같은 차례(433-21 ①) — 하한 · 측점 밖 막힘 뒤에 옮김 표시 + handle = pointer[pointer.index("const node = dragHandle.node;") :] + handle = handle[: handle.index("if (dragNode >= 0) {")] + assert "dragRaw.forEach((vertex, index) => (state.planned[index] = vertex));" in handle + assert handle.index("shortfallCrossed(") < handle.index("params.offStation() > dragOff") + assert handle.index("params.offStation() > dragOff") < handle.index("dragMoved = true;") assert "offStation: () => (stationSnap ? fit.offStationCount(" in edit assert edit.count("\n") <= 695 diff --git a/resources/tester/test_399_plan_structure_marks.py b/resources/tester/test_399_plan_structure_marks.py index 04acdd853..dea27acd8 100644 --- a/resources/tester/test_399_plan_structure_marks.py +++ b/resources/tester/test_399_plan_structure_marks.py @@ -76,7 +76,7 @@ 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)) {") + mark = pointer.index("if (choice.mark && marks.press(px, py)) {") # 433-21 ③ 가까운 것일 때 release = pointer.index("marks.deselect();") node = pointer.index("if (hitNode >= 0) {") handle = pointer.index("} else if (dragHandle) {") diff --git a/resources/tester/test_409_mark_top_deselect.py b/resources/tester/test_409_mark_top_deselect.py index 5bef3a97b..c6066095b 100644 --- a/resources/tester/test_409_mark_top_deselect.py +++ b/resources/tester/test_409_mark_top_deselect.py @@ -60,7 +60,7 @@ def test_빈_곳_누름은_마커가_고른_것만_풂(got) -> None: def test_누름_마커_먼저_교각점_풂() -> None: pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") - down = pointer[pointer.index("if (marks.press(px, py)) {") :] + down = pointer[pointer.index("if (choice.mark && 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 @@ -68,9 +68,10 @@ def test_누름_마커_먼저_교각점_풂() -> None: def test_맴돔_커서_마커_먼저() -> None: + """433-21 ③ — 맴돔 · 커서는 누름과 같은 판정(`chooseAt` · 노드 가운데 밖이면 가까운 것).""" 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);") + over = pointer.index("const onMark = choice?.mark === true;") + pick = pointer.index("const over = choice?.pick ?? null;") cursor = pointer.index("canvas.style.cursor = onMark") assert over < pick < cursor diff --git a/resources/tester/test_430_route_edit_rules.py b/resources/tester/test_430_route_edit_rules.py index bf24e9aea..2d8183ff2 100644 --- a/resources/tester/test_430_route_edit_rules.py +++ b/resources/tester/test_430_route_edit_rules.py @@ -157,7 +157,13 @@ def test_편집기_배선() -> None: assert "curveBar.noteFit(snap.text(), before, index)" in main # 놓을 때 맞춤 보고 snap = main.split("bindStationSnap(chrome", 1)[1][:400] assert "tellDraft();" in snap # 끔 · 간격 바꿈 즉시 계산 줄 - assert "snap.changed()" in main.split("function tellDraft()", 1)[1][:400] + assert ( + "lineChanged(history) || otherChanged()" in main.split("function tellDraft()", 1)[1][:400] + ) + assert ( + "const otherChanged = (): boolean => pickedTerrain() !== designTerrain || snap.changed();" + in main + ) # 433-14 ② assert "plan.node_edits" in main assert "nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc })" in main assert len(main.splitlines()) <= 695 diff --git a/resources/tester/test_453_plan_pick_draft.py b/resources/tester/test_453_plan_pick_draft.py new file mode 100644 index 000000000..dfe2bba39 --- /dev/null +++ b/resources/tester/test_453_plan_pick_draft.py @@ -0,0 +1,84 @@ +"""453 B05 평면 편집 — 집기 한 판정 · 손잡이 끌기 · 막힌 걸음 놓음 · 스냅만 바꾼 초안. + +못박는 것 + - 433-14 ③ · 433-21 ③ 노드 가운데 6 px = 노드 · 밖 = 마커 · 노드 · 손잡이 중 가까운 것 · 맴돔 커서 같은 판정. + - 433-14 ① 막힌 걸음만 하고 놓으면 미리보기 맞춤을 누르기 전 자리로(맞추지 않은 기준 13/13 → 0). + - 433-14 ② 스냅 · 분석 지형만 바꾼 뒤 노선이 처음으로 돌아가도 다리 초안 있음(null 아님). + - 433-21 ① 손잡이 끌기 훑기는 `test_390_5_snap_step_ops`. + +짝: `helper_453_plan_pick_draft.cjs`(진짜 TS `_Input` · `_History` · `_Draft` · `StationFit`). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +B05 = HERE.parents[1] / "B05_Profile" + + +def _read(name: str) -> str: + return (B05 / name).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def got() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_453_plan_pick_draft.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_노드_가운데만_노드_밖은_가까운_것(got) -> None: + p = got["pick"] + assert ( + p["handleSide"] == "handle" and p["handleCenter"] == "handle" + ) # 예전 = 노드 12 px 안이라 노드 + assert p["coreEdge"] == "pick" and p["nodeCore"] == "pick" + assert p["nodeCloser"] == "pick" and p["handleOnly"] == "handle" and p["nothing"] == "pick" + assert p["handleDistance"] == 3 + + +def test_마커_겹친_교점_가운데는_교점(got) -> None: + p = got["pick"] + assert p["markOnCore"] == "pick" # 예전 = 마커 먼저 + assert p["markCloser"] == "mark" and p["nodeBeatsMark"] == "pick" and p["tie"] == "mark" + + +def test_누름_맴돔_커서_한_판정() -> None: + pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts") + down = pointer[pointer.index('addEventListener("pointerdown"') :] + down = down[: down.index('addEventListener("pointermove"')] + assert "const choice = chooseAt(px, py);" in down + assert "if (choice.mark && marks.press(px, py)) {" in down + assert "dragHandle = choice.handle;" in down and "handleAt(px, py)" not in down + move = pointer[pointer.index('addEventListener("pointermove"') :] + move = move[: move.index("const endDrag")] + hover = move[move.index("const measuring") :] + assert "chooseAt(px, py)" in hover and "handleAt(px, py)" not in hover + assert "marks.over(" not in pointer and "pressWinner(" in pointer + + +def test_막힌_걸음만_놓으면_되돌림(got) -> None: + b = got["blocked"] + assert b["drags"] == 13 and b["previewMoved"] == 13 and b["leftAfterRelease"] == 0 + pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts") + end = pointer[pointer.index("const endDrag") :] + assert end.index("if (!dragMoved && dragRaw) {") < end.index("if (dragMoved) {") + + +def test_스냅만_바꾼_뒤_처음으로_초안_있음(got) -> None: + d = got["draft"] + assert d["snapChanged"] == [60, 50, 60, 50] # 처음 노선(50)이어도 초안 + assert d["same"] == [60, None, 60, None] and d["old"] == [60, None] + edit = _read("B05_Profile_UI_RouteEdit.ts") + assert "draftLink.watch(history, options.draft, snap.draft, otherChanged);" in edit + assert "const dirty = lineChanged(history) || otherChanged();" in edit + assert "changed: () => (!!history && lineChanged(history)) || otherChanged()," in edit