feat(D 예정노선): 저장 꼴에 지점 이름 · 선 이름 · 노드 기록 칸 · [저장] 몸 기준점 (PLAN 594-1 · 594-3 · 요구 463)

- 파일 points [{x, y, name?}] · lines[].name? · lines[].nodes?(꼭짓점 수와 같음 · kind free/length/slope/both · length_m · slope_pct) — 칸 없는 옛 파일 그대로 읽힘
- PUT 몸 point_names · site{lon, lat, address?} — 기준점이 바뀌면 같은 트랜잭션에서 projects.site_* 고침(주소 = 서버 VWorld · 못 찾으면 몸 주소)
- 이름 20 자 · 파일 이름 못 쓰는 글자 막음 — 서버 400(name_error) ↔ TS nameError 짝 · 노드 기록 수 틀림 400
- 선 이름 = 붙인 이름 · 없으면 차례 이름(lineNames ↔ line_names 짝 · 내보내기 조각 이름에 이어짐)
- TS 꼴 NodeKind · NodeRecord · PlanLine.name/nodes · BasePoint.name · ServerNetwork.point_names/site · searchPlaces 틀
- 시험 530_3 이름 거울 · 591_2 왕복 · 400 · 기준점

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 14:40:34 +09:00
co-authored by Claude Opus 5.5
parent 4cb77b2bcd
commit bf77b70be9
6 changed files with 319 additions and 19 deletions
+21 -1
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_Api.ts
* D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 ·
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 지점 · 기준점 함께 591-2) ·
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 지점 · 기준점 함께 591-2 ·
* 지점 · 선 이름 · 노드 기록 · [저장] 기준점 594) · 주소 · 장소 찾기(594-4 공용 `/map/search`) ·
* 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림 · 581 — 음영기복 · 보호구역 · 띠 · 산림입지도).
* ========================================================================== */
@@ -17,6 +18,8 @@ export interface BasePoint {
lat: number;
x: number;
y: number;
/** 지점 이름(594-1) — 없음 = 「지점 N」. */
name?: string;
}
/** 지점(긋기에 붙는 점 · 작업 좌표계 m + 경위도) · 등록 기준점(처음 받는 기준 · 591-2) ·
@@ -52,6 +55,10 @@ export interface ServerNetwork {
epsg: number;
/** 지점(PUT 몸만 · 빈 값 = 저장된 지점 그대로). */
points?: Vertex[];
/** 지점 이름(PUT 몸만 · points 와 같은 차례 · points 와 함께 · 빈 칸 = 「지점 N」 · 594-1). */
point_names?: Array<string | null>;
/** 기준점(PUT 몸만 · 바뀌면 서버가 등록 칸 `projects.site_*` 고침 · 주소 = 서버가 다시 찾음 · 594-3). */
site?: { lon: number; lat: number; address?: string | null };
lines: PlanLine[];
marks: Array<{ line: string; at_m: number }>;
pieces: PlanPiece[];
@@ -123,6 +130,19 @@ export const savePlanNetwork = (
): Promise<PlanNetworkView> =>
request(`${base(projectId)}/network`, { method: "PUT", body: JSON.stringify(network) });
/** 주소 · 장소 찾기 후보(594-4) — 주소 = 도로명 · 지번 · 장소 = 이름(`title`) + 주소. */
export interface PlaceHit {
kind: "address" | "place";
title: string;
address: string;
lon: number;
lat: number;
}
/** 주소 · 장소 찾기(서버 VWorld · 등록 D01 · 예정노선 위치 칸이 같이 씀) — 없음 = 빈 줄. */
export const searchPlaces = async (query: string): Promise<PlaceHit[]> =>
(await request<{ items: PlaceHit[] }>(`/map/search?q=${encodeURIComponent(query)}`)).items;
/** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…` · 격자 = 평활본 · 없으면 null. */
export async function fetchPlanSurface(projectId: string): Promise<PlanSurface | null> {
const { models } = await listSurfaceModels(projectId);
+130 -6
View File
@@ -9,7 +9,9 @@
길이 · 측점 · 높이는 저장하지 않음(늘 다시 셈 · 높이는 지형에서). 측점 = 선마다 No.0 부터.
조각 = 선의 마크 사이 구간(구역 × 갈래) · 업체 지정은 조각 단위(`plan_route_assignees.piece_id`).
지점(`points` · 긋기에 붙는 점) = 같은 파일 · [저장] 몸에 실림(빈 값 = 그대로) ·
기준점(`projects.site_*`) = 처음 자료 받는 기준일 뿐(노선 · 붙음 무관 · PLAN 591-2).
기준점(`projects.site_*`) = 처음 자료 받는 기준일 뿐(노선 · 붙음 무관 · PLAN 591-2) ·
[저장] 몸 `site` 가 바뀌었으면 같은 칸을 고침(594-3 · 등록과 한 칸).
지점 · 선 이름(20 자 · 파일 이름 못 쓰는 글자 막음) · 선 노드 기록 = 파일에 있을 때만(594-1).
계산 자리 = ① 파이썬·TS 짝(`D03_PlanAnalysis_Draw`) — 화면은 그리는 즉시 · [저장]은 여기서 다시 셈.
"""
@@ -18,12 +20,14 @@ from __future__ import annotations
import hashlib
import json
import math
from typing import Any
import re
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from B01_Dashboard.B01_Dashboard_Map import reverse_place
from common_util.common_util_auth import is_forest_service, require_company
from common_util.common_util_crs import tm_epsg_for_lon, tm_to_lonlat
from common_util.common_util_save_guard import project_lock
@@ -45,10 +49,29 @@ class Branch(BaseModel):
vertex: int = Field(ge=0)
class NodeRecord(BaseModel):
"""노드 기록(594-1) — 찍을 때 방식 · 길이(m) · 기울기(%) · 안 쓴 값 = None."""
kind: Literal["free", "length", "slope", "both"]
length_m: float | None = Field(default=None, allow_inf_nan=False)
slope_pct: float | None = Field(default=None, allow_inf_nan=False)
class Line(BaseModel):
id: str = Field(pattern=ID)
vertices: list[tuple[float, float]] = Field(min_length=1)
branch: Branch | None = None
# 594-1 — 빈 값 = 차례 이름 · 노드 기록 = 꼭짓점 수와 같음(없음 = 기록 없음)
name: str | None = None
nodes: list[NodeRecord | None] | None = None
class Site(BaseModel):
"""[저장] 몸 기준점(594-3) — 주소는 서버가 다시 찾음(못 찾으면 이 값)."""
lon: float = Field(ge=120, le=135)
lat: float = Field(ge=30, le=45)
address: str | None = Field(default=None, max_length=255)
class Mark(BaseModel):
@@ -68,20 +91,78 @@ class Network(BaseModel):
epsg: int
# 지점(작업 좌표계 m) — 빈 값(None) = 저장된 지점 그대로
points: list[tuple[float, float]] | None = Field(default=None, max_length=MAX_POINTS)
# 지점 이름(594-1) — points 와 같은 차례 · points 와 함께만 읽음 · 빈 칸 = 「지점 N」
point_names: list[str | None] | None = Field(default=None, max_length=MAX_POINTS)
lines: list[Line] = []
marks: list[Mark] = []
pieces: list[Piece] = []
site: Site | None = None
class NetworkError(ValueError):
pass
NAME_MAX = 20
NAME_BAD = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
def name_error(name: str) -> str | None:
"""이름 틀림 까닭 글 · 맞으면 None(앞뒤 빈칸 뺀 값으로 봄 · 594-1).
⚠ 짝 = TS `logic/D03_PlanAnalysis_Plan.ts` `nameError` — 거울 시험 530-3."""
text = name.strip()
if len(text) > NAME_MAX:
return f"이름은 {NAME_MAX} 자까지입니다."
if NAME_BAD.search(text):
return '이름에 \\ / : * ? " < > | 를 쓸 수 없습니다.'
return None
def _clean_name(name: str | None, what: str) -> str | None:
"""검사한 이름 · 빈 값 = None · 틀리면 400."""
if name is None:
return None
if error := name_error(name):
raise HTTPException(status_code=400, detail=f"{what} — {error}")
return name.strip() or None
def _check_points(points: list[tuple[float, float]] | None) -> None:
if points is not None and not all(math.isfinite(v) for p in points for v in p):
raise HTTPException(status_code=400, detail="지점 좌표가 수가 아닙니다.")
def _points_out(
points: list[tuple[float, float]], names: list[str | None] | None
) -> list[dict[str, Any]]:
"""파일 지점 꼴 `{x, y, name?}` — 이름 칸은 있을 때만."""
names = names or []
if len(names) > len(points):
raise HTTPException(status_code=400, detail="지점 이름이 지점보다 많습니다.")
out = []
for i, (x, y) in enumerate(points):
name = _clean_name(names[i] if i < len(names) else None, f"지점 {i + 1} 이름")
out.append({"x": float(x), "y": float(y), **({"name": name} if name else {})})
return out
def _lines_out(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""파일 선 꼴 — 이름 · 노드 기록 칸은 있을 때만(옛 꼴 그대로)."""
out = []
for line in lines:
name = _clean_name(line.get("name"), f"선 {line['id']} 이름")
base = {key: value for key, value in line.items() if key not in ("name", "nodes")}
out.append(
{
**base,
**({"name": name} if name else {}),
**({"nodes": line["nodes"]} if line.get("nodes") else {}),
}
)
return out
def _check(ok: bool, message: str) -> None:
if not ok:
raise NetworkError(message)
@@ -100,6 +181,11 @@ def derive(network: dict[str, Any], year_from: int | None = None) -> dict[str, A
all(math.isfinite(v) for point in vertices for v in point),
f"선 {line['id']} 점이 수가 아닙니다.",
)
nodes = line.get("nodes")
_check(
nodes is None or len(nodes) == len(vertices),
f"선 {line['id']} 노드 기록 수가 꼭짓점 수와 다릅니다.",
)
branch = line.get("branch")
if branch:
parent = lines.get(branch["line"])
@@ -221,10 +307,17 @@ def work_epsg(plan: dict[str, Any], site: dict[str, Any] | None) -> int | None:
def base_view(plan: dict[str, Any], site: dict[str, Any] | None) -> dict[str, Any]:
"""{epsg, points: [{lon, lat, x, y}], site} — 지점(TM) + 경위도 · 기준점(경위도 · 주소)."""
"""{epsg, points: [{lon, lat, x, y, name?}], site} — 지점(TM) + 경위도 · 이름 ·
기준점(경위도 · 주소)."""
epsg = work_epsg(plan, site)
points = [
{"lon": lon, "lat": lat, "x": float(p["x"]), "y": float(p["y"])}
{
"lon": lon,
"lat": lat,
"x": float(p["x"]),
"y": float(p["y"]),
**({"name": p["name"]} if p.get("name") else {}),
}
for p in plan["points"]
if epsg is not None
for lon, lat in [tm_to_lonlat(float(p["x"]), float(p["y"]), epsg)]
@@ -308,6 +401,31 @@ async def _store_status(
)
async def _moved_site(
current: dict[str, Any] | None, site: dict[str, Any] | None
) -> dict[str, Any] | None:
"""[저장] 몸 기준점(594-3) — 그대로면 None · 바뀌면 {lon, lat, address}(주소 = 서버 VWorld ·
못 찾으면 몸 주소)."""
if site is None or (
current
and abs(current["lon"] - site["lon"]) < 1e-9
and abs(current["lat"] - site["lat"]) < 1e-9
):
return None
found = (await reverse_place(site["lon"], site["lat"]))["address"]
address = found or (site.get("address") or "").strip() or None
return {"lon": float(site["lon"]), "lat": float(site["lat"]), "address": address}
async def _store_site(connection: Any, project_id: UUID, site: dict[str, Any]) -> None:
"""기준점 칸 — 등록(D01)과 같은 `projects.site_*`(사업 지역 · 이름은 안 바꿈)."""
async with connection.cursor() as cursor:
await cursor.execute(
"UPDATE projects SET site_lon = %s, site_lat = %s, site_address = %s WHERE id = %s",
(site["lon"], site["lat"], site["address"], str(project_id)),
)
@router.put("/{project_id}/plan/network", response_model=None)
async def put_plan_network(
project_id: UUID,
@@ -322,6 +440,8 @@ async def put_plan_network(
raise HTTPException(status_code=403, detail="예정노선은 산림청만 고칠 수 있습니다.")
network = payload.model_dump()
points = network.pop("points")
point_names = network.pop("point_names")
site = network.pop("site")
async with project_lock(str(project_id)):
root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400
if plan["epsg"] is not None and plan["epsg"] != network["epsg"]:
@@ -332,15 +452,19 @@ async def put_plan_network(
except NetworkError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
_check_points(points)
plan = {**plan, **network}
plan = {**plan, **network, "lines": _lines_out(network["lines"])}
if points is not None:
plan["points"] = [{"x": float(x), "y": float(y)} for x, y in points]
plan["points"] = _points_out(points, point_names)
site = await _moved_site(project["site"], site)
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
await _store_status(
connection, project_id, derived["pieces"], year_rows(derived["pieces"])
)
if site:
await _store_site(connection, project_id, site)
project = {**project, "site": site}
store.save_plan(root, plan)
await connection.commit()
except Exception:
@@ -2,7 +2,7 @@
* D03_PlanAnalysis_Plan.ts
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) ·
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3).
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1).
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
* ========================================================================== */
@@ -11,12 +11,38 @@ import { lineLength, segmentLength, sliceLine } from "./D03_PlanAnalysis_Draw";
export type Vertex = [number, number];
/** 노드 긋기 방식(594-1) — 그리기 칸 방식 값 그대로. */
export type NodeKind = "free" | "length" | "slope" | "both";
/** 노드 기록(594-1) — 그 노드를 찍을 때 방식 · 길이(m) · 기울기(%) · 안 쓴 값 = null. */
export interface NodeRecord {
kind: NodeKind;
length_m: number | null;
slope_pct: number | null;
}
/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) ·
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0). */
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0) · `name` 없음 = 차례 이름(`lineNames`) ·
* `nodes` = 꼭짓점마다 기록(같은 길이 · 칸 없음 = 기록 없음 · 594-1). */
export interface PlanLine {
id: string;
vertices: Vertex[];
branch: { line: string; vertex: number } | null;
name?: string | null;
nodes?: Array<NodeRecord | null> | null;
}
/** 지점 · 선 이름 한도(594-1). */
export const NAME_MAX = 20;
const NAME_BAD = /[\\/:*?"<>|\u0000-\u001f]/;
/** 이름 틀림 까닭 글 · 맞으면 null(앞뒤 빈칸 뺀 값으로 봄).
* ⚠ 짝 = `D03_PlanAnalysis_Network.name_error`(서버 400) — 거울 시험 530-3. */
export function nameError(name: string): string | null {
const text = name.trim();
if (text.length > NAME_MAX) return `이름은 ${NAME_MAX} 자까지입니다.`;
if (NAME_BAD.test(text)) return '이름에 \\ / : * ? " < > | 를 쓸 수 없습니다.';
return null;
}
/** 조각 = 선(id) 위 [from_m, to_m] 구역 · 연도(구역 마크 = 조각 경계 · id = 업체 지정이 붙는 열쇠라 나눠도 앞 조각이 지님). */
@@ -262,15 +288,17 @@ export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: numbe
.map((piece) => ({ line: piece.line, at_m: piece.from_m }));
}
/** 선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례).
/** 선 이름 — 붙인 이름(594-1) · 없으면 차례 이름: 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) ·
* 갈래 = 갈래 1 · 2 …(선 차례 · 이름 붙은 선도 수를 셈).
* ⚠ 짝 = `D04_PlanConfirm_Repository.line_names`(조각 · 내보내기 이름) — 고치면 같이(거울 시험 530-3). */
export function lineNames(lines: ReadonlyArray<Pick<PlanLine, "branch">>): string[] {
export function lineNames(lines: ReadonlyArray<Pick<PlanLine, "branch" | "name">>): string[] {
const roots = lines.filter((line) => !line.branch).length;
let root = 0;
let branch = 0;
return lines.map((line) =>
line.branch ? `갈래 ${++branch}` : roots > 1 ? `본선 ${++root}` : "본선",
);
return lines.map((line) => {
const auto = line.branch ? `갈래 ${++branch}` : roots > 1 ? `본선 ${++root}` : "본선";
return line.name?.trim() || auto;
});
}
/** 선 차례 — 갈래는 부모 뒤(서버 셈이 앞에서부터 부모를 찾음) · 이미 맞으면 그대로. */
@@ -26,7 +26,8 @@ async def get_plan_project(project_id: str) -> dict[str, Any] | None:
def line_names(lines: list[dict[str, Any]]) -> list[str]:
"""선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례).
"""선 이름 — 붙인 이름(594-1) · 없으면 차례 이름: 갈래 없는 선 = 본선(둘 이상이면
본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례 · 이름 붙은 선도 수를 셈).
⚠ 짝 = TS `D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts` `lineNames` — 거울 시험 530-3."""
roots = sum(1 for line in lines if not line.get("branch"))
@@ -34,10 +35,11 @@ def line_names(lines: list[dict[str, Any]]) -> list[str]:
for line in lines:
if line.get("branch"):
branch += 1
out.append(f"갈래 {branch}")
auto = f"갈래 {branch}"
else:
root += 1
out.append(f"본선 {root}" if roots > 1 else "본선")
auto = f"본선 {root}" if roots > 1 else "본선"
out.append((line.get("name") or "").strip() or auto)
return out
+29 -2
View File
@@ -3,7 +3,8 @@
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 ·
서버 꼴 읽기 · 화면 꼴(선 · 마크 · 조각)을 서버 셈(`derive`)이 그대로 받음 ·
580 = 종점에서 그은 선 뒤집어 잇기 · 갈래로 붙음 · 꺾임점 옮김 · 지움(마크 비율) ·
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names`).
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names` · 붙인 이름 594-1) ·
이름 규칙 거울(`nameError` ↔ `name_error` · 594-1).
./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q
"""
@@ -101,12 +102,22 @@ const names = [
plan.lineNames([{ branch: null }]),
plan.lineNames([{ branch: null }, { branch: null }, { branch: { line: "main", vertex: 1 } }]),
plan.lineNames([{ branch: null }, { branch: { line: "main", vertex: 1 } }]),
plan.lineNames([
{ branch: null, name: " 임도 A " },
{ branch: null },
{ branch: { line: "main", vertex: 1 }, name: "" },
]),
];
const B = String.fromCharCode(92);
const NAME_CASES = ["가".repeat(20), "가".repeat(21), " 임도 ", "", "a/b", "a" + B + "b",
"a:b", "a*b", "a?b", 'a"b', "a<b", "a>b", "a|b", "a" + String.fromCharCode(9) + "b",
"용화-1_(2027)"];
const nameErrors = NAME_CASES.map((name) => [name, plan.nameError(name)]);
writeFileSync(process.argv[2], JSON.stringify({
one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read,
marks: plan.marksOf(branched),
empty, joined, sameYear, asBranch, m, movedV, removedV, removeBlocked, lineBlocked, lineGone,
reversed, branchFlip, lineYear, markMoved, markClamp, names,
reversed, branchFlip, lineYear, markMoved, markClamp, names, nameErrors, NAME_MAX: plan.NAME_MAX,
}));
"""
@@ -260,7 +271,23 @@ def test_line_names_mirror(tmp_path: Path) -> None:
[{"branch": None}],
[{"branch": None}, {"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
[{"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
[
{"branch": None, "name": " 임도 A "},
{"branch": None},
{"branch": {"line": "main", "vertex": 1}, "name": ""},
],
]
assert got["names"] == [line_names(c) for c in cases]
assert got["names"][3] == ["임도 A", "본선 2", "갈래 1"] # 붙인 이름 · 빈 이름 = 차례 이름
assert got["names"][1] == ["본선 1", "본선 2", "갈래 1"] # 시점 둘 = 둘째도 본선
assert got["names"][2] == ["본선", "갈래 1"] # 옛 꼴 이름 그대로
def test_name_rule_mirror(tmp_path: Path) -> None:
"""594-1 이름 규칙 — TS `nameError` ↔ 서버 `name_error` 같은 판정 · 20 자 · 금지 글자."""
got = _run_ts(tmp_path)
assert got["NAME_MAX"] == server.NAME_MAX == 20
for name, error in got["nameErrors"]:
assert (error is None) == (server.name_error(name) is None), name
ok = {name for name, error in got["nameErrors"] if error is None}
assert ok == {"가" * 20, " 임도 ", "", "용화-1_(2027)"}
@@ -6,6 +6,8 @@
- 옛 지점 칸(시점 · 종점 목록 · 한 점 꼴)은 열 때 옮겨 보임.
- 핀 끌기 = 그 자리 노선 노드 같이(TS `moveNodesAt` · 붙은 갈래 첫 점 · 다른 점은 그대로).
- D02 길 · 단계 · 폴더가 남지 않음.
- 594-1 지점 · 선 이름 · 노드 기록 왕복 · 옛 파일 읽힘 · 21 자 · 금지 글자 400 ·
594-3 [저장] 몸 기준점 → 등록 칸(바뀔 때만 · 주소 = 서버 다시 찾음).
./venv/Scripts/python.exe -m pytest resources/tester/test_591_2_plan_points.py -q
"""
@@ -158,6 +160,103 @@ def test_epsg_stays_once_saved(api) -> None:
assert got.status_code == 400 # 파일 머리 좌표계 한 곳
# --- 594-1 지점 · 선 이름 · 노드 기록 ---
NODES = [None, {"kind": "both", "length_m": 20.0, "slope_pct": 8.5}]
def test_names_nodes_round_trip(api) -> None:
# 옛 파일(이름 · 기록 칸 없음) 그대로 읽힘
store.save_plan(
api.state["root"],
{**store.empty_plan(5186), "points": [{"x": A[0], "y": A[1]}], "lines": [LINE]},
)
old = api.get(URL).json()
assert "name" not in old["base"]["points"][0] and "name" not in old["network"]["lines"][0]
line = {**LINE, "name": " 용화 본선 ", "nodes": NODES}
branch = {
"id": "branch-1",
"vertices": [list(B), [B[0] + 50, B[1]]],
"branch": {"line": "main", "vertex": 1},
}
body = {
"epsg": 5186,
"points": [list(A), list(B)],
"point_names": ["시점", None],
"lines": [line, branch],
}
got = api.put(URL, json=body)
assert got.status_code == 200, got.text
view = api.get(URL).json()
assert [p.get("name") for p in view["base"]["points"]] == ["시점", None]
main, side = view["network"]["lines"]
assert main["name"] == "용화 본선" and main["nodes"] == NODES
assert "name" not in side and "nodes" not in side # 빈 칸 = 옛 꼴 그대로
saved = _saved(api)
assert saved["points"][1] == {"x": B[0], "y": B[1]}
# 이름만 다시 [저장] = 같은 값
again = api.put(URL, json={**body, "lines": view["network"]["lines"]}).json()
assert again["network"]["lines"] == view["network"]["lines"]
@pytest.mark.parametrize(
"patch",
[
{"lines": [{**LINE, "name": "가" * 21}]},
{"lines": [{**LINE, "name": "a/b"}]},
{"lines": [{**LINE, "name": 'a"b'}]},
{"point_names": ["시:점"]},
{"point_names": ["가" * 21]},
{"point_names": ["a", "b"]}, # 지점보다 많음
{"lines": [{**LINE, "nodes": [None]}]}, # 꼭짓점 둘 · 기록 하나
],
)
def test_bad_names_nodes_400(api, patch) -> None:
body = {"epsg": 5186, "points": [list(A)], "lines": [LINE], **patch}
assert api.put(URL, json=body).status_code == 400
assert not store.plan_path(api.state["root"]).exists() # 안 씀
def test_name_20_ok_and_bad_kind_422(api) -> None:
body = {"epsg": 5186, "points": [list(A)], "point_names": ["가" * 20], "lines": [LINE]}
assert api.put(URL, json=body).status_code == 200
bad = {**LINE, "nodes": [None, {"kind": "grade", "length_m": 1, "slope_pct": 1}]}
assert api.put(URL, json={"epsg": 5186, "lines": [bad]}).status_code == 422
# --- 594-3 [저장] 몸 기준점 → 등록 칸 ---
def test_site_saved_with_network(api, monkeypatch) -> None:
written = []
async def store_site(connection, project_id, site):
written.append(site)
async def place(lon, lat):
return {"address": f"주소 {lon:.3f}", "region": "어딘가"}
monkeypatch.setattr(net, "_store_site", store_site)
monkeypatch.setattr(net, "reverse_place", place)
same = {"lon": SITE["lon"], "lat": SITE["lat"]}
api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": same})
api.put(URL, json={"epsg": 5186, "lines": [LINE]})
assert written == [] # 그대로 · 빈 값 = 안 씀
moved = {"lon": 127.2, "lat": 37.6, "address": "몸 주소"}
got = api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": moved}).json()
assert written == [{"lon": 127.2, "lat": 37.6, "address": "주소 127.200"}] # 서버가 다시 찾음
assert got["base"]["site"] == written[0]
async def no_place(lon, lat):
return {"address": None, "region": None}
monkeypatch.setattr(net, "reverse_place", no_place)
api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": {**moved, "lon": 127.3}})
assert written[-1]["address"] == "몸 주소" # 못 찾으면 몸 주소
bad = api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": {"lon": 200, "lat": 37}})
assert bad.status_code == 422
# --- D02 걷음 ---