320 lines
15 KiB
Python
320 lines
15 KiB
Python
"""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 안에 갈래"]
|