Files
Aislo/resources/tester/test_254_1_b08_milestone_bases.py
T
eomsangdonandClaude Opus 5.5 7fdbcc4794 feat(B08): 거리이정표 여러 기준점 — bases[] · 경로 to · 옛 base 옮김 · 사슬 셈(254-1)
- 데이터 = 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
2026-10-11 13:58:00 +09:00

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