"""D03 예정노선 노선 세트 화면 셈(PLAN 638-2 · 요구 547 · 548) — TS `logic/D03_PlanAnalysis_Group.ts` · `Plan.ts` 세트 칸. tsc 로 옮겨 Node 로 돌림. - 거울: `fixtures/d03_638_set_cases.json` 사례마다 fromServer → mergeSets = 기대 조각(서버 `Network.derive` 와 같은 답). - 세트 지정: 고른 구간 끝에서 나눔 · 되돌려 지정하면 하나로(앞 id) · 경계 1 m 안 = 그 경계 · 두 선 구간. - 고를 세트: 있는 세트 + 해마다 새 번호(최댓값 + 1) · 지워도 다른 번호 그대로. - 새 선 = 사업연도 시작 해 새 번호 · 갈래 = 붙은 자리 세트 · 이어 긋기 = 끝 세트 그대로. - [잇기]: 닿음 · 떨어짐(덧 토막 = 앞 선 세트) · 머리끼리 · 같은 선 · 갈래 첫 점 = 까닭 글. - 기존 동작: 마크 · 토막 지움 · 방향 바꿈에 세트가 따라감 · 이어 긋기 이음 자리 = 같은 세트만 합침. ./venv/Scripts/python.exe -m pytest resources/tester/test_638_group.py -q """ from __future__ import annotations import json import re import subprocess from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] LOGIC = ROOT / "D03_PlanAnalysis" / "logic" TS_FILES = [LOGIC / f"D03_PlanAnalysis_{name}.ts" for name in ("Draw", "Plan", "Select", "Group")] TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" CASES = Path(__file__).resolve().parent / "fixtures" / "d03_638_set_cases.json" _RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; import * as group from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.js"; import { removeSegment } from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.js"; const Y = 2027; const view = (net) => (typeof net === "string" ? net : { lines: net.lines.map((l) => ({ id: l.id, vertices: l.vertices, branch: l.branch })), pieces: net.pieces.map(({ id, line, from_m, to_m, year, set }) => ({ id, line, from_m, to_m, year, set })), }); const piece = (id, line, from_m, to_m, year, set) => ({ id, line, from_m, to_m, year, set }); const line = (id, vertices, branch = null) => ({ id, vertices, branch }); const out = {}; // 거울 const fixture = JSON.parse(readFileSync(process.argv[3], "utf-8")); out.mirror = fixture.cases.map((c) => { const n = c.network; return view(group.mergeSets(plan.fromServer(n.lines, n.marks, n.pieces, fixture.year_start))).pieces; }); // 세트 지정 const one = { lines: [line("main", [[0, 0], [100, 0]])], pieces: [piece("p1", "main", 0, 100, Y, 1)] }; const assigned = group.assignSet(one, [{ line: "main", from_m: 30, to_m: 60 }], { year: Y, set: 2 }); out.assign = view(assigned); out.assignBack = view(group.assignSet(assigned, [{ line: "main", from_m: 60, to_m: 30 }], { year: Y, set: 1 })); out.assignNear = view(group.assignSet(assigned, [{ line: "main", from_m: 0.5, to_m: 30.4 }], { year: 2028, set: 1 })); const two = { lines: [line("main", [[0, 0], [100, 0]]), line("line-1", [[0, 50], [100, 50]])], pieces: [piece("p1", "main", 0, 100, Y, 1), piece("p2", "line-1", 0, 100, Y, 2)], }; out.assignTwo = view(group.assignSet(two, [ { line: "main", from_m: 50, to_m: 100 }, { line: "line-1", from_m: 0, to_m: 40 }, ], { year: 2028, set: 1 })); // 마크만 놓인 다른 선은 지정에 안 걷힘 const marked = plan.splitPiece(two, "line-1", 70); out.assignKeepsOtherMark = view(group.assignSet(marked, [{ line: "main", from_m: 0, to_m: 20 }], { year: Y, set: 3 })); // 고를 세트 const many = { lines: [line("main", [[0, 0], [300, 0]])], pieces: [ piece("p1", "main", 0, 100, Y, 1), piece("p2", "main", 100, 200, Y, 3), piece("p3", "main", 200, 300, 2028, 1), ], }; out.choices = group.setChoices(many, [2027, 2028, 2029]).map((c) => [c.key, c.fresh]); const dropped = plan.removeMark(many, "main", 100, 0.1); // 2027-3 사라짐 = 다른 번호 그대로 out.choicesAfterDrop = group.setChoices(dropped, [2027]).map((c) => [c.key, c.fresh]); out.key = group.setKey({ year: 2027, set: 2 }); // 새 선 · 갈래 · 이어 긋기 let net = plan.normalizePieces(plan.appendVertex(plan.newLine(one, [0, 80]), 1, [50, 80]), Y); out.newLine = view(net).pieces; const yearly = { lines: [line("main", [[0, 0], [50, 0], [100, 0]])], pieces: [piece("p1", "main", 0, 25, Y, 1), piece("p2", "main", 25, 100, 2028, 4)], }; net = plan.normalizePieces(plan.appendVertex(plan.addBranch(yearly, 0, 1), 1, [50, 40]), Y); out.branch = view(net).pieces; out.extend = view(plan.normalizePieces(plan.appendVertex(yearly, 0, [150, 0]), Y)).pieces; out.split = view(plan.splitPiece(one, "main", 40)).pieces; out.reverse = view(plan.reverseLine(yearly, 0)).pieces; out.cutMiddle = view(plan.normalizePieces(removeSegment(yearly, { line: "main", index: 0 }), Y)).pieces; const mid = { lines: [line("main", [[0, 0], [30, 0], [60, 0], [100, 0]])], pieces: [piece("p1", "main", 0, 100, Y, 1)] }; out.cutInner = view(plan.normalizePieces(removeSegment(mid, { line: "main", index: 1 }), Y)); // 이어 긋기 이음(attachHead) — 같은 세트 = 하나 · 다른 세트 = 둘 const pair = (setB) => ({ lines: [line("main", [[0, 0], [50, 0]]), line("line-1", [[50, 0], [100, 0]])], pieces: [piece("p1", "main", 0, 50, Y, 1), piece("p2", "line-1", 0, 50, Y, setB)], }); out.attachSame = view(plan.normalizePieces(plan.attachHead(pair(1), 1), Y)); out.attachOther = view(plan.normalizePieces(plan.attachHead(pair(2), 1), Y)); // [잇기] out.joinTouch = view(group.joinEnds(pair(2), { line: "main", head: false }, { line: "line-1", head: true }, Y)); out.joinTouchSame = view(group.joinEnds(pair(1), { line: "line-1", head: true }, { line: "main", head: false }, Y)); const gap = { lines: [line("main", [[0, 0], [50, 0]]), line("line-1", [[60, 0], [100, 0]])], pieces: [piece("p1", "main", 0, 50, Y, 1), piece("p2", "line-1", 0, 40, Y, 2)], }; out.joinGapA = view(group.joinEnds(gap, { line: "main", head: false }, { line: "line-1", head: true }, Y)); out.joinGapB = view(group.joinEnds(gap, { line: "line-1", head: true }, { line: "main", head: false }, Y)); const heads = { lines: [line("main", [[0, 0], [-50, 0]]), line("line-1", [[0, 0], [50, 0]])], pieces: [piece("p1", "main", 0, 50, Y, 1), piece("p2", "line-1", 0, 50, 2028, 1)], }; out.joinHeads = view(group.joinEnds(heads, { line: "main", head: true }, { line: "line-1", head: true }, Y)); const tails = { lines: [line("main", [[0, 0], [50, 0]]), line("line-1", [[100, 0], [60, 0]])], pieces: [piece("p1", "main", 0, 50, Y, 1), piece("p2", "line-1", 0, 40, Y, 2)], }; out.joinTails = view(group.joinEnds(tails, { line: "main", head: false }, { line: "line-1", head: false }, Y)); out.joinSame = group.joinEnds(pair(1), { line: "main", head: true }, { line: "main", head: false }, Y); const withBranch = plan.normalizePieces(plan.appendVertex(plan.addBranch(yearly, 0, 1), 1, [50, 40]), Y); out.joinBranchHead = group.joinEnds(withBranch, { line: "branch-1", head: true }, { line: "main", head: false }, Y); out.joinBranchTail = view(group.joinEnds( { ...withBranch, lines: [...withBranch.lines, line("line-2", [[50, 40], [50, 90]])], pieces: [...withBranch.pieces, piece("p9", "line-2", 0, 50, 2029, 1)] }, { line: "branch-1", head: false }, { line: "line-2", head: true }, Y)); // 저장 뒤 경고(638-3) — 빈틈 = 다른 덩이 끝끼리 가장 짧은 거리 · 닿은 조각 = 한 덩이 const planned = [ { id: "a", planned: [[0, 0], [50, 0]] }, { id: "b", planned: [[50, 0], [80, 0]] }, { id: "c", planned: [[115, 0], [200, 0]] }, { id: "d", planned: [[0, 10], [0, 60]] }, ]; out.gapOne = group.setGapM([planned[0].planned, planned[1].planned]); out.gapTwo = group.setGapM(planned.slice(0, 3).map((p) => p.planned)); out.warnings = group.setWarnings([ { key: "2027-1", parts: 2, branch: false, pieces: ["a", "b", "c"] }, { key: "2027-2", parts: 1, branch: true, pieces: ["d"] }, { key: "2028-1", parts: 1, branch: false, pieces: ["d"] }, ], planned); writeFileSync(process.argv[2], JSON.stringify(out)); """ @pytest.fixture(scope="module") def r(tmp_path_factory: pytest.TempPathFactory) -> dict: tmp = tmp_path_factory.mktemp("group") out = tmp / "js" subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(TSC), *map(str, TS_FILES), "--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 / "out.json" subprocess.run( # noqa: S603 ["node", str(out / "runner.mjs"), str(result), str(CASES)], cwd=str(ROOT), check=True, capture_output=True, ) return json.loads(result.read_text(encoding="utf-8")) def _span(pieces: list[dict]) -> list[tuple]: return [(p["id"], p["line"], p["from_m"], p["to_m"], p["year"], p["set"]) for p in pieces] def test_거울_사례_서버와_같은_조각(r): cases = json.loads(CASES.read_text(encoding="utf-8"))["cases"] assert len(cases) == len(r["mirror"]) >= 6 for case, got in zip(cases, r["mirror"]): assert _span(got) == _span(case["expect"]), case["name"] def test_세트_지정_끝에서_나눔_되돌리면_하나(r): assert _span(r["assign"]["pieces"]) == [ ("p1", "main", 0, 30, 2027, 1), ("p2", "main", 30, 60, 2027, 2), ("p3", "main", 60, 100, 2027, 1), ] # 거꾸로 고른 구간도 같음 · 셋이 한 세트 = 앞 조각 id 하나(마크 걷음) assert _span(r["assignBack"]["pieces"]) == [("p1", "main", 0, 100, 2027, 1)] def test_경계_1m_안은_그_경계(r): assert _span(r["assignNear"]["pieces"]) == [ ("p1", "main", 0, 30, 2028, 1), ("p2", "main", 30, 60, 2027, 2), ("p3", "main", 60, 100, 2027, 1), ] def test_두_선_구간_지정_다른_선_마크는_그대로(r): assert _span(r["assignTwo"]["pieces"]) == [ ("p1", "main", 0, 50, 2027, 1), ("p3", "main", 50, 100, 2028, 1), ("p2", "line-1", 0, 40, 2028, 1), ("p4", "line-1", 40, 100, 2027, 2), ] other = [p for p in r["assignKeepsOtherMark"]["pieces"] if p["line"] == "line-1"] assert [(p["from_m"], p["to_m"]) for p in other] == [(0, 70), (70, 100)] def test_고를_세트_새_번호(r): assert r["key"] == "2027-2" assert r["choices"] == [ ["2027-1", False], ["2027-3", False], ["2027-4", True], ["2028-1", False], ["2028-2", True], ["2029-1", True], ] # 2027-3 이 사라져도 2027-1 그대로 · 새 번호 = 남은 최댓값 + 1 assert r["choicesAfterDrop"] == [ ["2027-1", False], ["2027-2", True], ["2028-1", False], ["2028-2", True], ] # 넘긴 해 밖이라도 있는 세트의 해는 목록에 def test_새_선_사업연도_새_번호_갈래_붙은_자리_이어_긋기(r): assert [(p["line"], p["year"], p["set"]) for p in r["newLine"]] == [ ("main", 2027, 1), ("line-1", 2027, 2), ] branch = [p for p in r["branch"] if p["line"] == "branch-1"] assert [(p["year"], p["set"]) for p in branch] == [(2028, 4)] # 붙은 자리(50 m) = p2 assert _span(r["extend"]) == [("p1", "main", 0, 25, 2027, 1), ("p2", "main", 25, 150, 2028, 4)] def test_기존_동작_세트가_따라감(r): assert [(p["id"], p["set"]) for p in r["split"]] == [("p1", 1), ("p2", 1)] assert _span(r["reverse"]) == [("p1", "main", 75, 100, 2027, 1), ("p2", "main", 0, 75, 2028, 4)] assert _span(r["cutMiddle"]) == [("p2", "main", 0, 50, 2028, 4)] inner = r["cutInner"] assert [line["id"] for line in inner["lines"]] == ["main", "line-1"] assert [(p["line"], p["year"], p["set"]) for p in inner["pieces"]] == [ ("main", 2027, 1), ("line-1", 2027, 1), ] # 갈라져도 같은 세트 def test_이어_긋기_이음은_같은_세트만_합침(r): assert _span(r["attachSame"]["pieces"]) == [("p1", "main", 0, 100, 2027, 1)] assert _span(r["attachOther"]["pieces"]) == [ ("p1", "main", 0, 50, 2027, 1), ("p2", "main", 50, 100, 2027, 2), ] def test_잇기_닿음(r): one = r["joinTouch"] assert [line["id"] for line in one["lines"]] == ["main"] assert one["lines"][0]["vertices"] == [[0, 0], [50, 0], [100, 0]] assert _span(one["pieces"]) == [ ("p1", "main", 0, 50, 2027, 1), ("p2", "main", 50, 100, 2027, 2), ] assert _span(r["joinTouchSame"]["pieces"]) == [("p1", "main", 0, 100, 2027, 1)] def test_잇기_떨어짐_덧_토막_앞_선_세트(r): a = r["joinGapA"] # 앞 = main 꼬리 → 덧 토막 50~60 = 2027-1 assert a["lines"][0]["vertices"] == [[0, 0], [50, 0], [60, 0], [100, 0]] assert _span(a["pieces"]) == [("p1", "main", 0, 60, 2027, 1), ("p2", "main", 60, 100, 2027, 2)] b = r["joinGapB"] # 앞 = line-1 머리 → 덧 토막 = 2027-2 assert len(b["lines"]) == 1 and b["lines"][0]["vertices"][0] == [0, 0] assert [(p["from_m"], p["to_m"], p["set"]) for p in b["pieces"]] == [(0, 50, 1), (50, 100, 2)] def test_잇기_머리끼리_꼬리끼리(r): heads = r["joinHeads"] assert len(heads["lines"]) == 1 and len(heads["lines"][0]["vertices"]) == 3 assert [(p["year"], p["set"], p["to_m"] - p["from_m"]) for p in heads["pieces"]] == [ (2027, 1, 50), (2028, 1, 50), ] tails = r["joinTails"] assert tails["lines"][0]["vertices"] == [[0, 0], [50, 0], [60, 0], [100, 0]] assert [(p["from_m"], p["to_m"], p["set"]) for p in tails["pieces"]] == [ (0, 60, 1), (60, 100, 2), ] def test_잇기_못_함_까닭_글_갈래_꼬리는_됨(r): assert "같은 선" in r["joinSame"] assert "갈래 첫 점" in r["joinBranchHead"] tail = r["joinBranchTail"] ids = [line["id"] for line in tail["lines"]] assert "line-2" not in ids and "branch-1" in ids branch = next(line for line in tail["lines"] if line["id"] == "branch-1") assert branch["branch"] == {"line": "main", "vertex": 1} assert branch["vertices"] == [[50, 0], [50, 40], [50, 90]] assert [(p["year"], p["set"]) for p in tail["pieces"] if p["line"] == "branch-1"] == [ (2028, 4), (2029, 1), ] def test_저장_뒤_경고_빈틈(r): """638-3 — 닿은 조각 = 한 덩이(빈틈 0) · 덩이 둘 = 끝끼리 가장 짧은 거리 · 갈래 = 「안에 갈래」 · 멀쩡한 세트 = 글 없음.""" assert r["gapOne"] == 0 assert r["gapTwo"] == pytest.approx(35) assert r["warnings"] == ["2027-1 이 2 덩이 · 빈틈 35.0 m", "2027-2 안에 갈래"]