Files
Aislo/resources/tester/test_244_12_b08_milestone_base_view.py
T
eomsangdonandClaude Opus 5.5 2584325b16 feat(B08): 거리이정표 다이어그램 — 기준점 연결 점 · 주소 줄바꿈 · 선 글 두 줄 · 거리 비례 선 길이(254-2)
- 연결 점 = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소 + 알림)
- 기준점 상자 주소 · 주소 줄바꿈(최대 3줄) + 상자 높이 = 내용 · 항목 글 13px
- 선 글 = 구간마다 두 줄(도로 구분 / km) · 처음 자리 선 길이 = 100 + 90·log10(1 + km)(100 ~ 320)
- 밀어 냄 = 놓인 상자 · 놓인 선 둘 다 피함
- 시험 = test_254_2 새로 · 244 · 250 기대값(두 줄 · 짧아진 선) · 244_9 node 입력 = stdin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 14:07:14 +09:00

226 lines
10 KiB
Python

"""244-12 B08 거리이정표 다이어그램(사용자 142~144번) — ① 넉넉한 고정 바탕(공사지만 있어도
거래처 · 기준점 자리까지 · 넘는 상자가 생길 때만 넓힘) ② 시점 → 중점 선(공사지 바탕 위) · 시점 ·
중점 상자 작은 글씨 주소 · 좌표(위도, 경도 소수 5자리) · 중점 주소 = 지금 중점 측점(?mid_m=)
③ 거래처 상자 = 항목 · 이름 · 주소(줄임표 + 호버).
"""
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_Milestone_Calc import migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
LONG = "경상남도 거창군 북상면 병곡리 968-31"
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def _node(expr):
src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e",
IMPORTS.sub("", 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 _route(rid, dir=None, via=True, address="", item=""):
return {"id": rid, "name": rid, "address": address, "via_base": via, "dir": dir,
"lon": None, "lat": None, "legs": [],
"rows": [{"item": item, "dest": "start", "note": ""}]} # fmt: skip
def _data(routes, base=False):
return {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": base, "name": "", "address": "", "dir": None, "legs": []},
"routes": routes,
}
def _clipped(note, box_w):
# 줄임표 · 안쪽 여백(양쪽 8) 안 · 앞부분 그대로
assert note.endswith("…") and LONG.startswith(note[:-1])
assert _node(f'textW("{note}", 10)') <= box_w - 16
def _lay(data, site=None):
data = migrate_milestone(data) # 254 옛 꼴 → bases · to(옛 기준점 = b1)
return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)")
def test_바탕_고정_범위_넘을_때만_넓힘():
base_view = _node("BASE_VIEW")
assert _lay(_data([]))["view"] == base_view # 공사지만 있어도 넉넉한 바탕
vx, vy, vw, vh = base_view
assert vw >= 900 and vh >= 800
# 공사지 둘레 8방 거래처 · 기준점(서) — 바탕 그대로(다시 줌아웃 없음)
dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
for lay in (_lay(_data([_route(d, d, via=False) for d in dirs])), _lay(_data([], base=True))):
assert lay["view"] == base_view
for b in lay["boxes"]:
assert vx <= b["x"] and b["x"] + b["w"] <= vx + vw
assert vy <= b["y"] and b["y"] + b["h"] <= vy + vh
# 기준점 너머 거래처 = 바탕 넘음 → 그쪽만 넓힘
lay = _lay(_data([_route("far", "W")], base=True))
x, y, w, h = lay["view"]
far = next(b for b in lay["boxes"] if b["id"] == "far")
assert x < vx and x <= far["x"] - 48 and x + w == vx + vw and (y, h) == (vy, vh)
def test_시점_중점_선_상자_주소_좌표():
site = {"start_lonlat": [127.812345678, 35.7], "mid_lonlat": [127.9, 35.712341],
"start_address": LONG, "mid_address": None} # fmt: skip
lay = _lay(_data([]), site)
boxes = {b["id"]: b for b in lay["boxes"]}
note, coord = boxes["start"]["notes"]
_clipped(note, boxes["start"]["w"])
assert coord == "35.70000, 127.81235"
assert boxes["start"]["tip"] == LONG # 호버 = 줄이지 않은 주소
assert boxes["mid"]["notes"] == ["35.71234, 127.90000"] # 주소 없음 = 좌표만
assert _lay(_data([]))["boxes"][1]["notes"] == [] # 좌표도 없음 = 빈 줄
line = next(ln for ln in lay["lines"] if ln["from"] == "start")
assert line["to"] == "mid" and line["labels"] == ["비포장\n1km"] # 254 두 줄
s, m = boxes["start"], boxes["mid"]
assert m["x"] - (s["x"] + s["w"]) >= 100 # 선 글 자리
for b in (s, m): # 상자 = 공사지 안
site_box = boxes["site"]
assert site_box["x"] <= b["x"] and b["x"] + b["w"] <= site_box["x"] + site_box["w"]
assert site_box["y"] + 20 < b["y"] and b["y"] + b["h"] <= site_box["y"] + site_box["h"]
def test_거래처_상자_항목_이름_주소():
lay = _lay(_data([_route("g", "N", via=False, address=LONG, item="장비"),
_route("k", "E", via=False, address="거창읍")])) # fmt: skip
boxes = {b["id"]: b for b in lay["boxes"]}
assert (boxes["g"]["items"], boxes["g"]["label"]) == ("장비", "g")
# 254 긴 주소 = 줄바꿈(낱말 사이) + 상자 세로 늘림(줄 × 15 + 13 · 최소 58)
assert boxes["g"]["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"]
assert boxes["g"]["h"] == 4 * 15 + 13 and boxes["k"]["h"] == 58
assert boxes["g"]["tip"] == LONG and boxes["k"]["notes"] == ["거창읍"]
assert _node('clip("가나다", 100, 10)') == "가나다"
assert _node(f'clip("{LONG}", 108, 10)') == "경상남도 거창군 북상…" # 한글 10 · 빈칸 6 · … 6
def test_화면_선_순서_글_줄_중점_주소():
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
# 선 = 공사지 바탕 뒤 · 다른 상자 앞(시점 → 중점 선 보임)
assert 'if (box.kind === "site") shift.append(lineLayer);' in ui
assert '...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"])' in ui
assert 'if (box.tip) g.append(svg("title", {}, box.tip));' in ui
assert "siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null)" in ui
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "site-address${query}" in state and "?mid_m=${midM}" in state
assert ".b08-milestone__note {" in _read("B08_Quantity/B08_Quantity_UI_Milestone.css")
for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_State.ts"):
assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700
def test_중점_주소_지금_측점(tmp_path, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
async def root(_pid):
return tmp_path
async def project(_pid):
return {"region": ""}
async def where(lon, lat):
return f"주소 {lon:.5f}"
monkeypatch.setattr(ms, "_project_root", root)
monkeypatch.setattr(ms, "get_project_by_id", project)
monkeypatch.setattr(ms, "reverse_geocode", where)
app = FastAPI()
app.include_router(ms.router)
client = TestClient(app)
path = tmp_path / "B05_Profile" / "route" / "planned_route.csv"
path.parent.mkdir(parents=True)
path.write_text("sequence,x,y\n0,208000,367000\n1,209000,367000", encoding="utf-8")
url = f"/api/projects/{uuid4()}/quantity/milestone/site-address"
half = client.get(url).json() # 저장본 없음 = 기본(연장 ÷ 2)
near = client.get(url, params={"mid_m": 100}).json()
assert near["start_lonlat"] == half["start_lonlat"]
assert near["start_lonlat"][0] < near["mid_lonlat"][0] < half["mid_lonlat"][0]
assert near["mid_address"] == f"주소 {near['mid_lonlat'][0]:.5f}"
assert client.get(url, params={"mid_m": -1}).status_code == 422
def _hits(a, b, gap=0):
return (a["x"] < b["x"] + b["w"] + gap and b["x"] < a["x"] + a["w"] + gap
and a["y"] < b["y"] + b["h"] + gap and b["y"] < a["y"] + a["h"] + gap) # fmt: skip
def _seg_hits(x1, y1, x2, y2, r):
t0, t1, dx, dy = 0.0, 1.0, x2 - x1, y2 - y1
for p, q in ((-dx, x1 - r["x"]), (dx, r["x"] + r["w"] - x1),
(-dy, y1 - r["y"]), (dy, r["y"] + r["h"] - y1)): # fmt: skip
if p == 0:
if q < 0:
return False
continue
t = q / p
t0, t1 = (max(t0, t), t1) if p < 0 else (t0, min(t1, t))
if t0 > t1:
return False
return True
def test_여유_상자_선_글_겹침_0():
"""④(145번) — 몰린 꼴(기준점 거침 셋 같은 방위 · 방위 없음 둘 · 공사지 동쪽 셋 ·
서쪽 = 기준점 자리)."""
legs = [{"road": "2차포장", "km": 23.8}]
routes = [{**_route(f"n{i}", "N", address=LONG, item="장비"), "legs": legs} for i in range(3)]
routes += [{**_route(f"f{i}"), "legs": legs} for i in range(2)]
routes += [{**_route(f"e{i}", "E", via=False), "legs": legs} for i in range(3)]
routes += [{**_route("w", "W", via=False), "legs": legs}] # 기준점과 같은 방위 = 밀려남
data = _data(routes, base=True)
data["base"]["legs"] = [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]
lay = _lay(data)
gap = _node("GAP")
boxes = lay["boxes"]
inside = {("site", "start"), ("site", "mid")}
for i, a in enumerate(boxes): # 상자 사이 여유(공사지 안 시점 · 중점만 품음)
for b in boxes[i + 1 :]:
if (a["id"], b["id"]) not in inside:
assert not _hits(a, b, gap - 0.01), (a["id"], b["id"])
for ln in lay["lines"]: # 선 끝 = 어느 상자 안도 아님(바깥 → 시점 = 공사지 테두리에서 끊김)
for x, y in ((ln["x1"], ln["y1"]), (ln["x2"], ln["y2"])):
for b in boxes:
if not (b["id"] == "site" and ln["from"] == "start"):
assert not (b["x"] < x < b["x"] + b["w"] and b["y"] < y < b["y"] + b["h"]), (
ln["from"],
ln["to"],
b["id"],
x,
y,
)
rects = {ln["from"]: _node(f"labelRect({json.dumps(ln, ensure_ascii=False)})")
for ln in lay["lines"] if ln["labels"]} # fmt: skip
for k, r in rects.items(): # 선 글 ↔ 다른 선
for o in lay["lines"]:
if o["from"] != k:
assert not _seg_hits(o["x1"], o["y1"], o["x2"], o["y2"], r), (k, o["from"])
keys = list(rects)
for i, k in enumerate(keys): # 선 글 ↔ 상자 · 다른 선 글
for b in boxes:
if not (b["kind"] == "site" and k == "start"):
assert not _hits(rects[k], b), (k, b["id"])
for j in keys[i + 1 :]:
assert not _hits(rects[k], rects[j]), (k, j)