feat(예정노선): 지점 칸 [추가] · 이름 · 경위도 손 고침 · 칸 설명 글 걷음 · 선마다 차례 색 (PLAN 597-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<typeof field>, 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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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', "a<b", "a>b", "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"] == ["", "가"]
|
||||
Reference in New Issue
Block a user