- 데이터 = bases[](id · to = 시점 또는 다른 기준점 · linked) · 경로 via_base 대신 to - 옛 꼴(base · via_base) = 읽을 때 · 받을 때 새 꼴로 옮김(migrate 짝 Py · TS · 저장 = 새 꼴) - 행 합 = 거래처 구간 + 기준점 사슬 구간들(방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) - 도로 구분 = 시점에 닿는 기준점 구간 1차 · 그 밖 기준점 구간 · 거래처 → 기준점 2차 - 화면 = 기준점 여럿 등록 · 삭제(이은 상자 안 이음) · 연결 놓기 = 기준점 id · 길찾기 · 표지 사슬 - 시험 = test_254_1 새로 · 244 · 250 기대값 새 꼴로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
214 lines
9.8 KiB
Python
214 lines
9.8 KiB
Python
"""254-1 B08 거리이정표 여러 기준점(사용자 162-10) — `bases[]` · 경로 `to` · 옛 꼴 옮김 ·
|
|
기준점 사슬 셈(파이썬 · TS 거울) · 돌기 막음 ·
|
|
도로 구분(시점에 닿는 기준점 구간 = 1차 · 그 밖 = 2차).
|
|
|
|
견본 사슬 = 거창군청 → 기준점 A(병곡리) → 기준점 B(신원) → 시점 · 김천역 → B → 시점 ·
|
|
철근 상회 → 시점(직접).
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
|
|
from B08_Quantity.B08_Quantity_Haul import with_haul_routes # noqa: E402
|
|
from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402
|
|
base_chain,
|
|
compute_milestone,
|
|
migrate_milestone,
|
|
)
|
|
|
|
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
|
B08 = "B08_Quantity/B08_Quantity_"
|
|
|
|
|
|
def _read(rel):
|
|
return (ROOT / rel).read_text(encoding="utf-8")
|
|
|
|
|
|
def _node(files, expr):
|
|
src = "\n".join(IMPORTS.sub("", _read(f)) for f in files)
|
|
result = subprocess.run(
|
|
["node", "--experimental-strip-types", "--no-warnings",
|
|
"--input-type=module-typescript", "-e", src + f"\nconsole.log(JSON.stringify({expr}));"],
|
|
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
|
) # fmt: skip
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def _leg(road, km):
|
|
return {"road": road, "km": km}
|
|
|
|
|
|
def _base(bid, to, legs, linked=True):
|
|
return {"id": bid, "name": bid, "address": "", "dir": None, "legs": legs, "to": to,
|
|
"linked": linked} # fmt: skip
|
|
|
|
|
|
def _route(rid, to, legs, dest="start"):
|
|
return {"id": rid, "name": rid, "address": "", "dir": None, "to": to, "legs": legs,
|
|
"rows": [{"item": "장비", "dest": dest, "note": ""}]} # fmt: skip
|
|
|
|
|
|
CHAIN = {
|
|
"site": {"mid_station_m": 800, "mid_road": "비포장"},
|
|
"bases": [
|
|
_base("A", "B", [_leg(None, 4.5), _leg("비포장", 0.3)]),
|
|
_base("B", "site", [_leg(None, 0.6)]),
|
|
],
|
|
"routes": [
|
|
_route("g", "A", [_leg(None, 23.8)]),
|
|
_route("k", "B", [_leg(None, 61.2)], dest="mid"),
|
|
_route("c", "site", [_leg(None, 2.0)]),
|
|
],
|
|
}
|
|
|
|
OLD = {
|
|
"site": {"mid_station_m": None, "mid_road": "비포장"},
|
|
"base": {"enabled": True, "name": "병곡리", "address": "주소", "dir": "E", "lon": 127.85,
|
|
"lat": 35.66, "pos": [10, 20], "legs": [_leg("1차포장", 0.6)]},
|
|
"routes": [
|
|
{"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None,
|
|
"legs": [_leg("2차포장", 23.8)], "rows": [{"item": "장비", "dest": "start", "note": ""}]},
|
|
{"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None,
|
|
"legs": [_leg(None, 5)], "rows": [{"item": "철근", "dest": "start", "note": ""}]},
|
|
],
|
|
} # fmt: skip
|
|
|
|
|
|
def test_옛_꼴_옮김_거울():
|
|
new = migrate_milestone(OLD)
|
|
assert "base" not in new and [b["id"] for b in new["bases"]] == ["b1"]
|
|
b1 = new["bases"][0]
|
|
assert (b1["to"], b1["linked"], b1["name"], b1["pos"]) == ("site", True, "병곡리", [10, 20])
|
|
assert b1["legs"] == [_leg("1차포장", 0.6)] and b1["lon"] == 127.85
|
|
assert [(r["id"], r["to"], "via_base" in r) for r in new["routes"]] == [
|
|
("r1", "b1", False),
|
|
("r2", "site", False),
|
|
]
|
|
# 꺼진 기준점 = bases 없음 · 거침 경로도 시점으로
|
|
off = migrate_milestone({**OLD, "base": {**OLD["base"], "enabled": False}})
|
|
assert off["bases"] == [] and [r["to"] for r in off["routes"]] == ["site", "site"]
|
|
assert migrate_milestone(new) == new # 새 꼴 = 그대로
|
|
# TS 짝 — 옮긴 꼴 · 표 합이 같음
|
|
calc = [B08 + "Milestone_Calc.ts"]
|
|
ts = _node(calc, f"migrateMilestone({json.dumps(OLD)})")
|
|
assert ts["bases"][0]["id"] == "b1" and [r["to"] for r in ts["routes"]] == ["b1", "site"]
|
|
assert ts["bases"][0]["legs"] == b1["legs"] and ts["bases"][0]["pos"] == [10, 20]
|
|
assert _node(calc, f"computeMilestone({json.dumps(OLD)}, 1500)") == compute_milestone(
|
|
OLD, 1500.0
|
|
)
|
|
# 옛 표 합 그대로 — 2차 23.8 + 1차 0.6
|
|
(a, b) = compute_milestone(OLD, 1500.0)
|
|
assert (a["s2"], a["s1"], a["total"]) == (23.8, 0.6, 24.4) and (b["pv"], b["total"]) == (5, 5)
|
|
|
|
|
|
def test_서버_읽기_받기_새_꼴(tmp_path, monkeypatch):
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
# 옛 파일 읽기 = 새 꼴(지우지 않음 · 저장 때 새 꼴)
|
|
folder = tmp_path / "B08_Quantity" / "milestone"
|
|
folder.mkdir(parents=True)
|
|
(folder / "milestone.json").write_text(json.dumps(OLD, ensure_ascii=False), "utf-8")
|
|
data = ms.read_data(tmp_path)
|
|
assert [b["id"] for b in data["bases"]] == ["b1"] and data["routes"][0]["to"] == "b1"
|
|
assert "base" not in data and "via_base" not in data["routes"][0]
|
|
|
|
async def root(_pid):
|
|
return tmp_path
|
|
|
|
monkeypatch.setattr(ms, "_project_root", root)
|
|
app = FastAPI()
|
|
app.include_router(ms.router)
|
|
client = TestClient(app)
|
|
url = f"/api/projects/{uuid4()}/quantity/milestone"
|
|
saved = client.put(url, json=OLD).json() # 옛 꼴 받기 = 새 꼴로 저장
|
|
stored = json.loads((folder / "milestone.json").read_text("utf-8"))
|
|
assert "base" not in stored and stored["bases"][0]["id"] == "b1"
|
|
assert stored["routes"][0]["to"] == "b1" and "via_base" not in stored["routes"][0]
|
|
assert saved["table"] == compute_milestone(OLD, None)
|
|
# 새 꼴 사슬 왕복 · 겹친 id · 공사지 상자 id = 거절
|
|
assert client.put(url, json=CHAIN).json()["data"]["bases"][0]["to"] == "B"
|
|
twice = {**CHAIN, "bases": [*CHAIN["bases"], _base("g", "site", [])]}
|
|
assert client.put(url, json=twice).status_code == 422
|
|
box = {**CHAIN, "bases": [_base("site", "site", [])]}
|
|
assert client.put(url, json=box).status_code == 422
|
|
# 운반 자동 상자 = 시점으로(안 이음)
|
|
(auto,) = with_haul_routes({"routes": []}, [{"key": "1", "name": "골재"}])["routes"]
|
|
assert auto["to"] == "site" and auto["linked"] is False and "via_base" not in auto
|
|
|
|
|
|
def test_기준점_둘_사슬_셈_거울():
|
|
g, k, c = compute_milestone(CHAIN, None)
|
|
# 거래처 → A = 2차 · A → B(기준점 → 기준점) = 2차 · B → 시점 = 1차 · 비포장은 사용자 값
|
|
assert (g["s2"], g["s1"], g["pv"], g["un"]) == (28.3, 0.6, 0, 0.3)
|
|
assert (g["paved"], g["unpaved"], g["total"]) == (28.9, 0.3, 29.2)
|
|
# 김천역 → B = 2차 · B → 시점 = 1차 · 목적지 중점 = 시점 → 중점 0.8 비포장
|
|
assert (k["s2"], k["s1"], k["un"], k["total"]) == (61.2, 0.6, 0.8, 62.6)
|
|
# 시점으로 바로 = 포장
|
|
assert (c["s2"], c["s1"], c["pv"], c["total"]) == (0, 0, 2.0, 2.0)
|
|
calc = [B08 + "Milestone_Calc.ts"]
|
|
assert _node(calc, f"computeMilestone({json.dumps(CHAIN)}, null)") == [g, k, c]
|
|
# 안 이은 기준점에서 사슬 멈춤(그 구간 · 뒤 구간 안 셈)
|
|
cut = {**CHAIN, "bases": [CHAIN["bases"][0], {**CHAIN["bases"][1], "linked": False}]}
|
|
g2, k2, _ = compute_milestone(cut, None)
|
|
assert (g2["s2"], g2["s1"], g2["un"], g2["total"]) == (28.3, 0, 0.3, 28.6)
|
|
assert (k2["s2"], k2["total"]) == (61.2, 62.0)
|
|
assert _node(calc, f"computeMilestone({json.dumps(cut)}, null)") == compute_milestone(cut, None)
|
|
# 선 자리 — 시점에 닿는 기준점 = base(1차) · 기준점 → 기준점 · 거래처 → 기준점 = via(2차)
|
|
kinds = _node(
|
|
calc, f"['g', 'A', 'B', 'c', 'start'].map((f) => segKind({json.dumps(CHAIN)}, f))"
|
|
)
|
|
assert kinds == ["via", "via", "base", "direct", "mid"]
|
|
|
|
|
|
def test_돌기_막음():
|
|
loop = {**CHAIN, "bases": [_base("A", "B", [_leg(None, 1)]), _base("B", "A", [_leg(None, 2)])]}
|
|
# 셈 = 방문 집합으로 끊음(A → B → A 멈춤 · 끝없이 안 돎)
|
|
assert [b["id"] for b in base_chain(loop, "A")] == ["A", "B"]
|
|
(g, *_) = compute_milestone(loop, None)
|
|
assert g["total"] == 26.8
|
|
calc = [B08 + "Milestone_Calc.ts"]
|
|
assert _node(calc, f"computeMilestone({json.dumps(loop)}, null)") == compute_milestone(
|
|
loop, None
|
|
)
|
|
self_loop = {**CHAIN, "bases": [_base("A", "A", [_leg(None, 1)])]}
|
|
assert [b["id"] for b in base_chain(self_loop, "A")] == ["A"]
|
|
# 연결 놓을 때 = 돌기면 취소(makesLoop) — A → B 사슬에서 B 를 A 에 이으면 돎
|
|
layout = [B08 + "Milestone_Layout.ts"]
|
|
d = json.dumps(CHAIN)
|
|
got = _node(
|
|
layout,
|
|
f"[makesLoop({d}, 'B', 'A'), makesLoop({d}, 'A', 'B'), makesLoop({d}, 'B', 'site'),"
|
|
f" makesLoop({d}, 'A', 'A'), chainOf({d}, 'A').map((b) => b.id),"
|
|
f" kmSegments({d}, 'g', [127.8, 35.6]).map((s) => s.text)]",
|
|
)
|
|
assert got[:4] == [True, False, False, True]
|
|
assert got[4] == ["A", "B"] and got[5] == ["g → A", "A → B", "B → 시점"]
|
|
|
|
|
|
def test_화면_기준점_여럿_등록_삭제():
|
|
modal = _read(B08 + "UI_Milestone_Modal.ts")
|
|
assert "fresh.bases.push({" in modal and 'to: "site",' in modal
|
|
assert "기준위치(있음" not in modal # 254 기준점 여럿 — 고르기 막음 없음
|
|
# 지운 기준점에 이은 상자 = 안 이음
|
|
assert "if (o.to === base.id)" in modal
|
|
state = _read(B08 + "UI_Milestone_State.ts")
|
|
assert "migrateMilestone(" in state and "bases: []," in state
|
|
page = _read(B08 + "UI_Milestone.ts")
|
|
assert "data.bases.length > 0" in page and 'to: "site",' in page
|
|
for name in ("Milestone_Calc.ts", "Milestone_Layout.ts", "UI_Milestone_Diagram.ts",
|
|
"UI_Milestone_Modal.ts", "UI_Milestone_State.ts", "UI_Milestone_LegModal.ts",
|
|
"UI_Milestone_Route.ts", "UI_Milestone.ts"): # fmt: skip
|
|
text = _read(B08 + name)
|
|
assert "data.base." not in text and ".via_base" not in text and ".base.legs" not in text
|