"""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', "ab", "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)"}