- 파일 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>
294 lines
13 KiB
Python
294 lines
13 KiB
Python
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1 · 580) — TS `logic/D03_PlanAnalysis_Plan.ts`.
|
||
|
||
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 ·
|
||
서버 꼴 읽기 · 화면 꼴(선 · 마크 · 조각)을 서버 셈(`derive`)이 그대로 받음 ·
|
||
580 = 종점에서 그은 선 뒤집어 잇기 · 갈래로 붙음 · 꺾임점 옮김 · 지움(마크 비율) ·
|
||
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(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
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from D03_PlanAnalysis import D03_PlanAnalysis_Network as server # noqa: E402
|
||
from D04_PlanConfirm.D04_PlanConfirm_Repository import line_names # noqa: E402
|
||
|
||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.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";
|
||
const Y = 2026;
|
||
const empty = plan.emptyNetwork();
|
||
let n = plan.newLine(empty, [0, 0]);
|
||
n = plan.appendVertex(n, 0, [100, 0]);
|
||
n = plan.appendVertex(n, 0, [100, 100]);
|
||
n = plan.normalizePieces(n, Y);
|
||
const one = n;
|
||
n = plan.normalizePieces(plan.splitPiece(n, "main", 50), Y);
|
||
const split = n;
|
||
const tooClose = plan.splitPiece(n, "main", 50.5);
|
||
n = plan.normalizePieces(plan.setPieceYear(n, 1, 2027), Y);
|
||
n = plan.addBranch(n, 0, 1);
|
||
n = plan.appendVertex(n, 1, [160, 0]);
|
||
n = plan.normalizePieces(n, Y);
|
||
const branched = n;
|
||
const inserted = plan.insertVertex(n, 0, 0, [40, 0]);
|
||
const blocked = plan.dropLastVertex(inserted, 0);
|
||
const dropped = plan.dropLastVertex(n, 1);
|
||
const guard = plan.dropLastVertex(plan.dropLastVertex(n, 0), 0);
|
||
const removed = plan.normalizePieces(plan.removeMark(n, "main", 50.4, 1), Y);
|
||
const grown = plan.normalizePieces(plan.appendVertex(n, 0, [100, 150]), Y);
|
||
const read = plan.fromServer(
|
||
n.lines,
|
||
[{ line: "main", at_m: 50 }, { line: "main", at_m: 150 }],
|
||
[{ id: "p9", line: "main", from_m: 0, to_m: 50, year: 2028 }],
|
||
Y,
|
||
);
|
||
// 580 — 본선(0,0)→(100,0) 반쯤 · 종점(200,0)에서 거꾸로 그은 선이 본선 꼬리에 닿음 → 한 선
|
||
let j = plan.normalizePieces(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), Y);
|
||
j = plan.newLine(j, [200, 0]);
|
||
j = plan.appendVertex(j, 1, [150, 50]);
|
||
j = plan.appendVertex(j, 1, [100, 0]);
|
||
j = plan.normalizePieces(j, 2027);
|
||
const joined = plan.normalizePieces(plan.attachHead(plan.reverseLine(j, 1), 1), Y);
|
||
const sameYear = plan.normalizePieces(
|
||
plan.attachHead(plan.reverseLine(plan.setLineYear(j, j.lines[1].id, Y), 1), 1),
|
||
Y,
|
||
);
|
||
// 종점에서 그은 선이 본선 꺾임점에 닿음 → 그 점의 갈래(선 차례 = 부모 뒤)
|
||
let b = plan.normalizePieces(
|
||
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [200, 0]),
|
||
Y,
|
||
);
|
||
b = plan.newLine(b, [100, 80]);
|
||
b = plan.appendVertex(b, 1, [100, 0]);
|
||
const asBranch = plan.attachHead(plan.reverseLine(b, 1), 1);
|
||
// 꺾임점 옮김 · 지움 — 마크 비율 · 붙은 갈래 첫 점
|
||
let m = plan.normalizePieces(
|
||
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
|
||
Y,
|
||
);
|
||
m = plan.normalizePieces(plan.splitPiece(m, "main", 150), Y);
|
||
m = plan.normalizePieces(plan.appendVertex(plan.addBranch(m, 0, 1), 1, [160, 0]), Y);
|
||
const movedV = plan.normalizePieces(plan.moveVertex(m, 0, 1, [100, -100]), Y);
|
||
let r = plan.normalizePieces(
|
||
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
|
||
Y,
|
||
);
|
||
r = plan.normalizePieces(plan.splitPiece(r, "main", 150), Y);
|
||
const removedV = plan.normalizePieces(plan.removeVertex(r, 0, 1), Y);
|
||
const removeBlocked = plan.removeVertex(m, 0, 1);
|
||
const lineBlocked = plan.removeLine(m, 0);
|
||
const lineGone = plan.normalizePieces(plan.removeLine(m, 1), Y);
|
||
const reversed = plan.normalizePieces(plan.reverseLine(m, 0), Y);
|
||
const branchFlip = plan.reverseLine(m, 1);
|
||
const lineYear = plan.setLineYear(m, "main", 2029);
|
||
const markMoved = plan.normalizePieces(plan.moveMark(m, 1, 120), Y);
|
||
const markClamp = plan.normalizePieces(plan.moveMark(m, 1, 500), Y);
|
||
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, nameErrors, NAME_MAX: plan.NAME_MAX,
|
||
}));
|
||
"""
|
||
|
||
|
||
def _run_ts(tmp_path: Path) -> dict:
|
||
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,
|
||
)
|
||
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 _spans(network: dict, line: str = "main") -> list[tuple[float, float, int]]:
|
||
return [(p["from_m"], p["to_m"], p["year"]) for p in network["pieces"] if p["line"] == line]
|
||
|
||
|
||
def test_pieces_cover_and_split(tmp_path: Path) -> None:
|
||
got = _run_ts(tmp_path)
|
||
assert _spans(got["one"]) == [(0, 200, 2026)]
|
||
assert _spans(got["split"]) == [(0, 50, 2026), (50, 200, 2026)]
|
||
assert [p["id"] for p in got["split"]["pieces"]] == ["p1", "p2"] # 앞 조각이 id 를 지님
|
||
assert got["tooClose"] == got["split"] # 마크 1 m 안 = 안 놓음
|
||
assert _spans(got["removed"]) == [(0, 200, 2026)] # 마크 지움 = 앞 조각 연도
|
||
assert _spans(got["grown"]) == [(0, 50, 2026), (50, 250, 2027)] # 선이 늘면 끝 조각이 늚
|
||
assert got["empty"] == {"lines": [], "pieces": []} # 빈 꼴 = 선 없음(시점 · 종점에서 시작)
|
||
|
||
|
||
def test_branch_order_and_guard(tmp_path: Path) -> None:
|
||
got = _run_ts(tmp_path)
|
||
branch = got["branched"]["lines"][1]
|
||
assert branch["branch"] == {"line": "main", "vertex": 1} and branch["vertices"][0] == [100, 0]
|
||
# 585-3 갈래 처음 해 = 붙은 자리(본선 100 m = 2027 구간) 해
|
||
assert _spans(got["branched"], branch["id"]) == [(0, 60, 2027)]
|
||
# 본선 앞 구간에 꺾임점을 끼우면 갈래가 붙은 차례가 한 칸 밀림
|
||
assert got["inserted"]["lines"][0]["vertices"][1] == [40, 0]
|
||
assert got["inserted"]["lines"][1]["branch"]["vertex"] == 2
|
||
assert got["blocked"]["lines"][0]["vertices"][-1] == [100, 0] # 끝 점은 갈래 없음 → 빠짐
|
||
assert "갈래가 붙은 점" in got["guard"] # 갈래가 붙은 점은 못 뺌
|
||
assert got["dropped"]["lines"][1]["vertices"] == [[100, 0]]
|
||
|
||
|
||
def test_from_server_fills_marks(tmp_path: Path) -> None:
|
||
got = _run_ts(tmp_path)
|
||
# 조각 없는 구역(마크만) = 처음 연도 · 새 id
|
||
assert _spans(got["read"]) == [(0, 50, 2028), (50, 150, 2026), (150, 200, 2026)]
|
||
assert got["read"]["pieces"][0]["id"] == "p9"
|
||
assert len({p["id"] for p in got["read"]["pieces"]}) == 4 # 갈래 조각 포함 겹침 없음
|
||
|
||
|
||
def test_server_accepts_screen_body(tmp_path: Path) -> None:
|
||
got = _run_ts(tmp_path)
|
||
body = {**got["branched"], "marks": got["marks"], "epsg": 5186}
|
||
derived = server.derive(body, 2026)
|
||
assert [(p["line"], p["from_m"], p["to_m"], p["year"]) for p in derived["pieces"]] == [
|
||
("main", 0.0, 50.0, 2026),
|
||
("main", 50.0, 200.0, 2027),
|
||
("branch-1", 0.0, 60.0, 2027),
|
||
]
|
||
|
||
|
||
def test_reversed_stroke_joins_or_branches(tmp_path: Path) -> None:
|
||
got = _run_ts(tmp_path)
|
||
joined = got["joined"]
|
||
# 종점에서 그은 선 = 뒤집혀 본선 꼬리에 이어 한 선 · No.0 = 시점 쪽 · 다른 해면 이음 자리 마크
|
||
assert len(joined["lines"]) == 1
|
||
assert joined["lines"][0]["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0]]
|
||
total = 100 + 2 * (50**2 * 2) ** 0.5
|
||
spans = _spans(joined)
|
||
assert spans[0] == (0, 100, 2026) and spans[1][0] == 100 and spans[1][2] == 2027
|
||
assert abs(spans[1][1] - total) < 1e-3
|
||
assert len(got["sameYear"]["pieces"]) == 1 # 같은 해면 이음 자리 마크 없음
|
||
branch = got["asBranch"]["lines"][1]
|
||
assert branch["branch"] == {"line": "main", "vertex": 1}
|
||
assert branch["vertices"] == [[100, 0], [100, 80]]
|
||
body = {**got["asBranch"], "marks": [], "pieces": [], "epsg": 5186}
|
||
server.derive(body, 2026) # 서버가 그대로 받음(갈래 첫 점 = 부모 점)
|
||
body = {**joined, "marks": [], "epsg": 5186}
|
||
body["marks"] = [
|
||
{"line": p["line"], "at_m": p["from_m"]} for p in joined["pieces"] if p["from_m"] > 0
|
||
]
|
||
assert len(server.derive(body, 2026)["pieces"]) == 2
|
||
|
||
|
||
def test_vertex_move_remove_keeps_marks(tmp_path: Path) -> None:
|
||
got = _run_ts(tmp_path)
|
||
# 본선 (0,0)-(100,0)-(100,100) · 마크 150 m(둘째 구간 반) · 갈래 = 꺾임점 1 에서 (160,0)
|
||
moved = got["movedV"]
|
||
assert moved["lines"][0]["vertices"][1] == [100, -100]
|
||
assert moved["lines"][1]["vertices"][0] == [100, -100] # 붙은 갈래 첫 점 같이
|
||
first = (100**2 + 100**2) ** 0.5
|
||
mark = [p for p in moved["pieces"] if p["line"] == "main"][1]["from_m"]
|
||
assert abs(mark - (first + 100)) < 1e-3 # 둘째 구간(길이 200) 반
|
||
removed = got["removedV"]
|
||
assert removed["lines"][0]["vertices"] == [[0, 0], [100, 100]]
|
||
share = 0.75 * (2 * 100**2) ** 0.5 # 합친 구간(200 m) 안 150 m = 3/4
|
||
assert abs(_spans(removed)[1][0] - share) < 1e-3
|
||
assert "갈래가 붙은 점" in got["removeBlocked"]
|
||
assert "갈래가 붙은 선" in got["lineBlocked"]
|
||
assert [line["id"] for line in got["lineGone"]["lines"]] == ["main"]
|
||
assert all(p["line"] == "main" for p in got["lineGone"]["pieces"])
|
||
assert "갈래는 방향을" in got["branchFlip"]
|
||
|
||
|
||
def test_reverse_year_mark_move(tmp_path: Path) -> None:
|
||
got = _run_ts(tmp_path)
|
||
rev = got["reversed"]
|
||
assert rev["lines"][0]["vertices"] == [[100, 100], [100, 0], [0, 0]]
|
||
assert rev["lines"][1]["branch"] == {"line": "main", "vertex": 1} # 3 − 1 − 1
|
||
assert _spans(rev) == [(0, 50, 2026), (50, 200, 2026)]
|
||
assert {p["year"] for p in got["lineYear"]["pieces"] if p["line"] == "main"} == {2029}
|
||
assert _spans(got["markMoved"]) == [(0, 120, 2026), (120, 200, 2026)]
|
||
assert _spans(got["markClamp"]) == [(0, 199, 2026), (199, 200, 2026)] # 끝에서 1 m 남김
|
||
body = {**rev, "marks": [{"line": "main", "at_m": 50}], "epsg": 5186}
|
||
server.derive(body, 2026)
|
||
|
||
|
||
def test_line_names_mirror(tmp_path: Path) -> None:
|
||
got = _run_ts(tmp_path)
|
||
cases = [
|
||
[{"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)"}
|