Files
Aislo/resources/tester/test_638_group.py
T

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 안에 갈래"]