From 0bf234e2b5fa8badbc9bce92442aeeb4000016ae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:46:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0):=20?= =?UTF-8?q?=EC=A7=80=EC=A0=90=20=EC=B9=B8=20[=EC=B6=94=EA=B0=80]=20=C2=B7?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B2=BD=EC=9C=84=EB=8F=84=20?= =?UTF-8?q?=EC=86=90=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EC=B9=B8=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EA=B8=80=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20=EC=84=A0?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=B0=A8=EB=A1=80=20=EC=83=89=20(PLAN=205?= =?UTF-8?q?97-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../D03_PlanAnalysis_UI_DrawPaint.ts | 9 +- .../D03_PlanAnalysis_UI_Points.ts | 173 ++++++++++++++++-- .../D03_PlanAnalysis_UI_Style.css | 18 ++ .../logic/D03_PlanAnalysis_PointEdit.ts | 76 ++++++++ resources/tester/test_597_point_edit.py | 111 +++++++++++ 5 files changed, 363 insertions(+), 24 deletions(-) create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts create mode 100644 resources/tester/test_597_point_edit.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 0e81c63f5..4aa572b55 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -6,7 +6,7 @@ * 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`. * ========================================================================== */ -import { pointColor } from "@ui/ui_template_palette"; +import { pointColor, routeColor } from "@ui/ui_template_palette"; import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { distanceAlong, @@ -18,6 +18,7 @@ import { type HeightAt, } from "./logic/D03_PlanAnalysis_Draw"; import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit"; import { planColors, PLAN_STATION_INTERVAL_M, @@ -126,7 +127,7 @@ export function paintNetwork( context.lineJoin = "round"; network.lines.forEach((line, index) => { if (line.vertices.length < 2) return; - context.strokeStyle = index === active ? colors.active : colors.line; + context.strokeStyle = index === active ? colors.active : routeColor(index); context.lineWidth = line.branch ? 2.5 : 3; context.beginPath(); line.vertices.forEach((v, i) => { @@ -163,7 +164,7 @@ export function paintNetwork( } /** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */ -export function pointLayer(points: () => Vertex[]): PlanMapLayer { +export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer { return { draw(context, map) { points().forEach((point, i) => { @@ -188,7 +189,7 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer { context.fillStyle = "#ffffff"; context.fill(); }); - planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px"); + planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px"); }); }, }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts index 7565f8f35..ba4044b6b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -1,7 +1,8 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Points.ts * D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로. - * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). + * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). + * [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음). * 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지). * 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 · * 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝. @@ -10,18 +11,27 @@ * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; +import { createButton } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { createListRow } from "@ui/ui_template_list_row"; import { pointColor } from "@ui/ui_template_palette"; -import { createText } from "@ui/ui_template_text"; import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + addPoint, + MAX_POINTS, + movePointTo, + parseLonLat, + pointLabel, + pointNameError, + renamePoint, + type PointsState, +} from "./logic/D03_PlanAnalysis_PointEdit"; import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */ -const MAX_POINTS = 40; /** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */ const PIN_HEAD_PX = 18; @@ -56,10 +66,22 @@ export interface PlanPoints { export function createPlanPoints(params: PlanPointsParams): PlanPoints { const list = document.createElement("div"); list.className = "d03-plan__points"; - const hint = createText("", "muted"); - hint.classList.add("d03-plan__hint"); + const addButton = createButton({ + label: "추가", + iconStart: "plus", + variant: "ghost", + size: 24, + onClick: addAtCenter, + }); + addButton.dataset.role = "points-add"; + const head = document.createElement("div"); + head.className = "d03-plan__points-head"; + head.append(addButton); const panel = document.createElement("div"); - panel.append(list, hint); + panel.append(head, list); + let openIndex: number | null = null; // 펼친 줄(하나만) + let syncEditor: (() => void) | null = null; + let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기) const menu = createMapContextMenu("d03-plan"); let menuAt: [number, number] = [0, 0]; let drag: { @@ -69,7 +91,14 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { last: [Vertex[], PlanNetwork] | null; } | null = null; - const name = (i: number): string => `지점 ${i + 1}`; + const name = (i: number): string => pointLabel(params.names(), i); + const state = (): PointsState => ({ + points: params.points(), + names: params.names(), + network: params.network(), + }); + const commitState = (next: PointsState): void => + params.commit(next.points, next.names, next.network); /** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */ function pinAt(px: number, py: number, map: ScreenMap): number | null { @@ -88,6 +117,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { } function remove(i: number): void { + openIndex = null; params.commit( params.points().filter((_, k) => k !== i), params.names().filter((_, k) => k !== i), @@ -134,7 +164,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { return true; } - const pins = pointLayer(params.points); + const pins = pointLayer(params.points, params.names); pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false); const input: PlanMapLayer = { @@ -163,12 +193,101 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { }, }; + /** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */ + function addAtCenter(): void { + const at = params.center(); + if (!at || !params.editable() || params.points().length >= MAX_POINTS) return; + openIndex = params.points().length; + commitState(addPoint(state(), at)); + } + + /** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */ + function editorOf(i: number): { root: HTMLElement; sync: () => void } { + const tm = tmParams(params.epsg()); + const editable = params.editable(); + const field = (label: string, role: string, placeholder?: string) => { + const f = createInputField({ label, size: 24, placeholder }); + f.input.dataset.role = role; + f.input.disabled = !editable || (role !== "point-name" && !tm); + return f; + }; + const nameField = field("이름", "point-name", `지점 ${i + 1}`); + const lonField = field("경도", "point-lon"); + const latField = field("위도", "point-lat"); + const lonLatNow = (): [number, number] | null => { + const p = params.points()[i]; + return tm && p ? tmToLonLat(tm, p[0], p[1]) : null; + }; + const sync = (): void => { + const set = (f: ReturnType, text: string): void => { + if (document.activeElement !== f.input) f.input.value = text; + }; + set(nameField, params.names()[i] ?? ""); + const ll = lonLatNow(); + set(lonField, ll ? ll[0].toFixed(6) : ""); + set(latField, ll ? ll[1].toFixed(6) : ""); + }; + nameField.input.addEventListener("input", () => + nameField.setError(pointNameError(nameField.input.value.trim()) ?? undefined), + ); + nameField.input.addEventListener("change", () => { + const text = nameField.input.value.trim(); + const error = pointNameError(text); + nameField.setError(error ?? undefined); + if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text)); + }); + const moved = (): void => { + const parsed = parseLonLat(lonField.input.value, latField.input.value); + const bad = typeof parsed === "string"; + lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined); + latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined); + if (bad || !tm) return; + const now = lonLatNow(); + if ( + now && + now[0].toFixed(6) === parsed[0].toFixed(6) && + now[1].toFixed(6) === parsed[1].toFixed(6) + ) + return; + commitState(movePointTo(state(), i, tm, parsed[0], parsed[1])); + }; + lonField.input.addEventListener("change", moved); + latField.input.addEventListener("change", moved); + const root = document.createElement("div"); + root.className = "d03-plan__point-edit"; + root.append(nameField.root, lonField.root, latField.root); + sync(); + return { root, sync }; + } + function render(): void { const tm = tmParams(params.epsg()); const editable = params.editable(); + const points = params.points(); + if (openIndex !== null && openIndex >= points.length) openIndex = null; + addButton.hidden = !editable; + addButton.disabled = points.length >= MAX_POINTS || !params.center(); + const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`; + const lonLatOf = (i: number): string | undefined => { + const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null; + return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined; + }; + if (key === shown) { + // 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움 + points.forEach((_, i) => { + const row = list.children[i]?.querySelector(".ui-list-row"); + const label = row?.querySelector(".ui-list-row__label"); + const sub = row?.querySelector(".ui-list-row__sub"); + if (label) label.textContent = name(i); + if (sub) sub.textContent = lonLatOf(i) ?? ""; + }); + syncEditor?.(); + return; + } + shown = key; + syncEditor = null; list.replaceChildren( - ...params.points().map((point, i) => { - const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null; + ...points.map((_, i) => { const drop = createIconButton({ icon: "trash", title: `${name(i)} 지우기`, @@ -176,22 +295,36 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { onClick: () => remove(i), }); drop.hidden = !editable; - return createListRow({ + const row = createListRow({ label: name(i), - sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined, + sub: lonLatOf(i), stripe: pointColor(i), size: 28, trailing: drop, + active: i === openIndex, }); + row.dataset.point = String(i); + row.tabIndex = 0; + const toggle = (): void => { + openIndex = openIndex === i ? null : i; + render(); + }; + row.addEventListener("click", (event) => { + if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle(); + }); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" && event.target === row) toggle(); + }); + const item = document.createElement("div"); + item.append(row); + if (i === openIndex) { + const editor = editorOf(i); + syncEditor = editor.sync; + item.append(editor.root); + } + return item; }), ); - hint.textContent = !editable - ? params.points().length - ? "보기만 — 지점은 산림청이 놓습니다." - : "지점 없음" - : params.points().length - ? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함." - : "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작)."; } return { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 14f869430..c97b85f4c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -195,3 +195,21 @@ .d03-plan__piece-year { font-weight: 600; } + +/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */ +.d03-plan__points-head { + display: flex; + justify-content: flex-end; + margin-bottom: var(--spacing-8); +} + +.d03-plan__point-edit { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) calc(var(--spacing-8) * 2); +} + +.d03-plan__point-edit > .ui-field { + margin: 0; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts new file mode 100644 index 000000000..06008fd45 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * D03_PlanAnalysis_PointEdit.ts + * D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/). + * 이름 = 20 자 안 · 파일 이름에 못 쓰는 글자(`/ \ : * ? " < > |`) 막음 · 빈 값 = 「지점 N」 보임. + * 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`). + * ⚠ 이름 한도 · 글자 = `Plan.ts` 의 `NAME_MAX` · `nameError`(594-1)와 같은 규칙 — 그쪽이 들어오면 거기서 가져옴. + * ========================================================================== */ + +import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan"; +import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm"; + +/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */ +export const MAX_POINTS = 40; +export const POINT_NAME_MAX = 20; +const BAD_CHARS = /[\\/:*?"<>|]/; + +/** 이름 까닭 글 — 쓸 수 있으면 null. */ +export function pointNameError(name: string): string | null { + if (name.length > POINT_NAME_MAX) return `이름은 ${POINT_NAME_MAX} 자까지`; + if (BAD_CHARS.test(name)) return '이름에 / \\ : * ? " < > | 는 못 씁니다'; + return null; +} + +/** 보이는 이름 — 빈 값 = 「지점 N」. */ +export function pointLabel(names: string[], index: number): string { + return names[index]?.trim() || `지점 ${index + 1}`; +} + +/** 경위도 글 → 값 · 못 읽거나 범위 밖이면 까닭 글. */ +export function parseLonLat(lonText: string, latText: string): [number, number] | string { + const lon = Number(lonText.trim()); + const lat = Number(latText.trim()); + if (!lonText.trim() || !Number.isFinite(lon) || Math.abs(lon) > 180) return "경도는 −180 ~ 180"; + if (!latText.trim() || !Number.isFinite(lat) || Math.abs(lat) > 90) return "위도는 −90 ~ 90"; + return [lon, lat]; +} + +export interface PointsState { + points: Vertex[]; + names: string[]; + network: PlanNetwork; +} + +/** 지점 더함 — 이름 비움. 상한이면 그대로. */ +export function addPoint(state: PointsState, at: Vertex): PointsState { + if (state.points.length >= MAX_POINTS) return state; + return { + ...state, + points: [...state.points, [at[0], at[1]]], + names: [...state.names, ""], + }; +} + +/** 이름 바꿈 — 빈 값 허용. */ +export function renamePoint(state: PointsState, index: number, name: string): PointsState { + const names = state.points.map((_, k) => state.names[k] ?? ""); + names[index] = name.trim(); + return { ...state, names }; +} + +/** 경위도로 옮김 — 그 자리 노선 노드도 같이. */ +export function movePointTo( + state: PointsState, + index: number, + tm: TmParams, + lon: number, + lat: number, +): PointsState { + const to = lonLatToTm(tm, lon, lat); + const from = state.points[index]; + return { + ...state, + points: state.points.map((p, k): Vertex => (k === index ? to : p)), + network: moveNodesAt(state.network, from, to), + }; +} diff --git a/resources/tester/test_597_point_edit.py b/resources/tester/test_597_point_edit.py new file mode 100644 index 000000000..f8f25848f --- /dev/null +++ b/resources/tester/test_597_point_edit.py @@ -0,0 +1,111 @@ +"""597-2 예정노선 지점 칸 손 고침 계산 — 이름 규칙 · 보이는 이름 · 경위도 읽기 · 더함 · 이름 바꿈 · 경위도로 옮김 +(요구 463 7 · 8) — TS `logic/D03_PlanAnalysis_PointEdit.ts`. + +./venv/Scripts/python.exe -m pytest resources/tester/test_597_point_edit.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_PointEdit.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; +import * as pe from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.js"; +import * as tmx from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Tm.js"; +const Y = 2026; +const tm = tmx.tmParams(5187); +// 지점 (100,0) 에서 시작하는 선 · 지점 둘 +let n = plan.newLine(plan.emptyNetwork(), [100, 0]); +n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y); +const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n }; +const added = pe.addPoint(state, [7, 8]); +const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n }; +const [lon, lat] = tmx.tmToLonLat(tm, 100, 0); +const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat); +const back = tmx.tmToLonLat(tm, ...moved.points[0]); +writeFileSync(process.argv[2], JSON.stringify({ + errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "ab", "a|b", "x".repeat(20), "x".repeat(21)] + .map((s) => pe.pointNameError(s)), + labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)], + parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")], + added: [added.points, added.names, added.network === n], + full: pe.addPoint(full, [1, 1]) === full, + renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names], + moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names }, +})); +""" + + +@pytest.fixture(scope="module") +def got(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp_path = tmp_path_factory.mktemp("pe") + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT), + "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", + "--ignoreConfig"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_name_rule(got: dict) -> None: + ok, bad = got["errors"][:2], got["errors"][2:11] + assert ok == [None, None] # 빈 이름 · 한글 = 됨 + assert all(msg and "못 씁니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉 + assert got["errors"][11] is None # 20 자 + assert "20 자" in got["errors"][12] # 21 자 = 막음 + + +def test_label_blank_falls_back(got: dict) -> None: + assert got["labels"] == ["지점 1", "지점 2", "가", "지점 4"] + + +def test_parse_lonlat(got: dict) -> None: + ok, no_lon, bad_lat, big_lon, big_lat = got["parse"] + assert ok == [128.5, 36.9] + assert no_lon.startswith("경도") and bad_lat.startswith("위도") + assert big_lon.startswith("경도") and big_lat.startswith("위도") + + +def test_add_and_limit(got: dict) -> None: + points, names, network_same = got["added"] + assert points == [[100, 0], [500, 500], [7, 8]] and names == ["", "가", ""] + assert network_same # 더해도 노선 그대로 + assert got["full"] # 40 개 = 그대로 + + +def test_rename(got: dict) -> None: + assert got["renamed"][0] == ["", "나"] # 앞뒤 빈칸 뺌 + assert got["renamed"][1] == ["", "다"] # 이름 목록이 짧아도 차례 맞춤 + + +def test_move_by_lonlat_drags_node(got: dict) -> None: + moved = got["moved"] + assert moved["back"] == pytest.approx(moved["expect"], abs=1e-7) # 경위도 왕복 + assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이 + assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로 + assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"]