Files
Aislo/resources/tester/test_244_13_b08_milestone_place_leg.py
T
eomsangdonandClaude Opus 5.5 5e25f418cc refactor(b08): 탭 표 자료 읽기 · 표 짜기를 화면 없는 파일로 꺼냄 — 구조물 집계표 · 자재 · 토공 · 수리 입력 · 거리이정표 · 화면은 같은 함수를 씀 · 동작 그대로 (302-3)
- 새 파일 = B08_Quantity_Summary_Table · Material_Tables · Earthwork_Tables · Hydro_Table · Milestone_Table
- 화면 UI 파일 = 불러오기 · 그리기만 · 옛 B08 시험 = 옮긴 글 자리 따라 읽는 파일 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-13 08:03:23 +09:00

311 lines
16 KiB
Python

"""244-13 B08 거리이정표 등록 모달 · 연결선 길찾기 · 도로 구분 열(사용자 147번 5 · 8).
도로 구분 = 기준점 거쳐 시점 들어가는 구간 2차포장 · 1차포장 · 비포장 · 그 밖 포장 · 비포장
(파이썬 · TS 거울) · 연결선 구간 끝 · 표 열(2차포장 = 기준점 있을 때만) · 소재지 역지오코딩 길 ·
고르는 지도(누름 · 끌기) · 연결선 한 구간 [길찾기](거래처 쪽만 자동 그림) — node 로 실제 TS 를 돌림.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
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 ( # noqa: E402
compute_milestone,
migrate_milestone,
)
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
B08 = "B08_Quantity/B08_Quantity_"
CALC = B08 + "Milestone_Calc.ts"
LAYOUT = B08 + "Milestone_Layout.ts"
KMAP = B08 + "UI_Milestone_KakaoMap.ts"
STATE = B08 + "UI_Milestone_State.ts"
BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone"
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def _node(rels, script, pre=""):
src = pre + "".join(IMPORTS.sub("", _read(rel)) for rel in rels)
result = subprocess.run( # 원본이 길어 명령 줄 대신 표준 입력
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=src + "\n" + script, 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, legs, via=True, rows=None, **kw):
return {"id": rid, "name": rid, "address": "", "via_base": via, "dir": None, "legs": legs,
"rows": rows or [{"item": "철근", "dest": "start", "note": ""}], **kw} # fmt: skip
NO_BASE = {
"site": {"mid_station_m": 800, "mid_road": "비포장"},
"base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []},
"routes": [
_route("g", [{"road": "포장", "km": 10.4}, {"road": "비포장", "km": 0.5}], rows=[
{"item": "철근", "dest": "start", "note": ""},
{"item": "혼합골재", "dest": "mid", "note": ""}]),
],
} # fmt: skip
WITH_BASE = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": True, "name": "병곡리", "address": "", "dir": None, "lon": 127.85,
"lat": 35.66, "legs": [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}]},
"routes": [
_route("g", [{"road": "2차포장", "km": 23.8}], lon=127.9, lat=35.68),
_route("d", [{"road": "포장", "km": 5}], via=False, lon=127.8, lat=35.6),
],
} # fmt: skip
def test_포장_도로_구분_거울():
a = compute_milestone(NO_BASE, 1500.0)
assert [(r["pv"], r["un"], r["paved"], r["total"]) for r in a] == [
(10.4, 0.5, 10.4, 10.9),
(10.4, 1.3, 10.4, 11.7), # 목적지 중점 = 시점 → 중점 0.8 km 비포장 더함
]
b = compute_milestone(WITH_BASE, 1500.0)
assert (b[0]["s2"], b[0]["s1"], b[0]["pv"], b[0]["un"]) == (23.8, 0.6, 0, 0.8)
assert (b[0]["paved"], b[0]["total"]) == (24.4, 25.2) # 견본 수식 = 포장 2차 + 1차
assert (b[1]["s2"], b[1]["pv"], b[1]["paved"], b[1]["total"]) == (0, 5, 5, 5) # 거침 안 함
ts = _node([CALC], f"console.log(JSON.stringify([computeMilestone({json.dumps(NO_BASE)}, "
f"1500), computeMilestone({json.dumps(WITH_BASE)}, 1500)]));") # fmt: skip
assert ts == [a, b]
assert ms.Leg.model_validate({"road": "포장", "km": 1}).road == "포장"
with pytest.raises(ValidationError):
ms.Leg.model_validate({"road": "3차포장"})
def test_도로_구분_갈래_연결선_구간_끝():
# 254 옛 꼴 → bases · to(옛 기준점 = b1)
d = json.dumps(migrate_milestone(WITH_BASE))
off = {**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}}
off = json.dumps(migrate_milestone(off))
got = _node(
[CALC, LAYOUT],
f"""const D = {d}; const O = {off}; const S = [127.83, 35.64];
console.log(JSON.stringify([["g", "d", "b1", "start"].map((f) => PAVED[segKind(D, f)]),
["g", "b1"].map((f) => segKind(O, f)),
segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "b1", S),
segmentEnds(D, "start", S), segmentEnds(O, "b1", S), segmentEnds(O, "g", S),
kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""",
)
# 152 포장 종류 = 선 자리 — 기준점 거침 거래처 2차 · 안 거침 포장 · 기준점 → 시점 1차 · 시점 → 중점 포장
assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "direct"]
assert got[2] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68],
"to": [127.85, 35.66]} # fmt: skip
assert got[3]["text"] == "d → 시점" and got[3]["to"] == [127.83, 35.64]
assert got[4] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66],
"to": [127.83, 35.64]} # fmt: skip
assert got[5] is None and got[6] is None # 시점 → 중점 = 손 입력 · 기준점 없음
assert got[7]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로
assert got[8] == ["route", "base"] and got[9] == ["route"]
def test_표_열_기준점_따라():
got = _node(
[B08 + "Milestone_Table.ts"],
"console.log(JSON.stringify([[false, false], [true, false], [true, true]].map(([b, p]) =>"
" milestoneColumns(b, p).map((c) => `${(c.group ?? [''])[0]}|${c.label}|${c.key}`))));",
)
assert got[0] == ["|항목|item", "|거래처 이름|company", "|목적지|dest",
"도로 구분 (km)|포장|paved", "도로 구분 (km)|비포장|un",
"계 (km)|합계|total", "|비고|note"] # fmt: skip
roads = [c for c in got[1] if c.startswith("도로 구분")]
assert roads == ["도로 구분 (km)|2차포장|s2", "도로 구분 (km)|1차포장|s1",
"도로 구분 (km)|비포장|un"] # fmt: skip
assert "계 (km)|포장|paved" in got[1] and "계 (km)|비포장|unpaved" in got[1]
assert "도로 구분 (km)|포장|pv" in got[2] # 기준점 있어도 거침 안 한 「포장」 값 = 포장 열
assert not any("2차포장" in c for c in got[0]) # 2차포장 열 = 기준점 있을 때만
def test_소재지_역지오코딩(monkeypatch):
asked = []
async def fake(lon, lat):
asked.append((lon, lat))
return "경남 거창군 북상면 병곡리 968-31"
monkeypatch.setattr(ms, "reverse_geocode", fake)
app = FastAPI()
app.include_router(ms.router)
client = TestClient(app)
res = client.get(BASE + "/reverse", params={"lon": 127.85, "lat": 35.66})
assert res.json() == {"address": "경남 거창군 북상면 병곡리 968-31"}
assert asked == [(127.85, 35.66)]
assert client.get(BASE + "/reverse", params={"lon": 127.85, "lat": 91}).status_code == 422
MAP_STUBS = """
const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {}, children: o.children ?? [] });
const requestAnimationFrame = (f: () => void) => setTimeout(f, 0);
(globalThis as any).window = globalThis;
(globalThis as any).document = { createElement: () => ({}), head: { append: () => undefined } };
const request = async () => ({ key: "fake-js-key" });
const LOG: any = { markers: [], handlers: [], lines: 0 };
class LatLng { lat: number; lng: number;
constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; }
getLat() { return this.lat; } getLng() { return this.lng; } }
const overlay = () => ({ setMap: () => undefined });
(globalThis as any).kakao = { maps: {
load: (f: () => void) => f(), LatLng,
LatLngBounds: class { extend() {} },
Map: class { relayout() {} setBounds() { LOG.fit = true; }
setCenter(p: any) { LOG.center = [p.lng, p.lat]; } setLevel(l: number) { LOG.level = l; } },
Polyline: function () { LOG.lines++; return overlay(); },
Marker: function (o: any) { const m = { ...overlay(), o, at: o.position,
getPosition: () => m.at }; LOG.markers.push(m); return m; },
CustomOverlay: function () { return overlay(); },
event: { addListener: (t: any, type: string, fn: any) => LOG.handlers.push([t, type, fn]) },
} };
"""
def test_고르는_지도_누름_끌기():
got = _node(
[KMAP],
"""
const moved: any[] = []; const clicked: any[] = [];
const m = editMap("p1", { hint: "h", onMove: (k, lon, lat) => moved.push([k, lon, lat]),
onClick: (lon, lat) => clicked.push([lon, lat]), center: () => [127.83, 35.64] });
m.set([], []);
await new Promise((r) => setTimeout(r, 20));
const empty = { center: LOG.center, level: LOG.level, hidden: m.root.children[1].hidden };
m.set([{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true },
{ key: "start", name: "시점", lon: 127.83, lat: 35.64, movable: false }],
[[[35.68, 127.9], [35.64, 127.83]]]);
await new Promise((r) => setTimeout(r, 20));
const click = LOG.handlers.find(([, type]) => type === "click");
click[2]({ latLng: new LatLng(35.7, 127.95) });
const drags = LOG.handlers.filter(([, type]) => type === "dragend");
const [r1] = LOG.markers.slice(-2);
r1.at = new LatLng(35.69, 127.91);
drags[0][2]();
console.log(JSON.stringify({ empty, moved, clicked, drags: drags.length, fit: !!LOG.fit,
lines: LOG.lines, draggable: LOG.markers.slice(-2).map((x: any) => !!x.o.draggable),
clicks: LOG.handlers.filter(([, t]) => t === "click").length }));
""",
MAP_STUBS,
)
assert got["empty"] == {
"center": [127.83, 35.64],
"level": 9,
"hidden": False,
} # 점 없음 = 시점
assert got["draggable"] == [True, False] and got["drags"] == 1 # 시점 = 고정
assert got["moved"] == [["r1", 127.91, 35.69]] and got["clicked"] == [[127.95, 35.7]]
assert got["fit"] and got["lines"] == 1 and got["clicks"] == 1 # 지도 누름 사건 한 번만
STATE_STUBS = """
const API_BASE_URL = "";
const showToast = () => undefined;
const store = new Map<string, any>();
const readState = (k: string, p: string) => store.get(k + p) ?? null;
const writeState = (k: string, v: any, p: string) => (store.set(k + p, v), WRITES.n++);
const clearState = (k: string, p: string) => store.delete(k + p);
const WRITES = { n: 0 };
const CALLS: any[] = [];
const request = async (url: string, init: any = {}) => {
CALLS.push([url, init.body ? JSON.parse(init.body) : null]);
if (url.endsWith("/milestone")) return { data: DATA, site: { start_lonlat: [127.83, 35.64],
mid_default_m: 1500 }, images: {}, auto_images: {} };
if (url.includes("/directions")) return { km: 10.5, reason: null,
path: [[35.68, 127.9], [35.66, 127.85]] };
return { name: "auto/g.png", reason: null };
};
"""
def test_연결선_한_구간_길찾기_거래처_쪽만_그림():
script = f"""const DATA = {json.dumps(WITH_BASE)};
await loadMilestone("p1");
const a = await findSegmentKm("p1", "g");
await new Promise((r) => setTimeout(r, 10));
const afterRoute = CALLS.length;
const b = await findSegmentKm("p1", "b1"); // 254 옛 꼴 캐시 → 기준점 id b1
const writes = WRITES.n;
await findSegmentKm("p1", "b1"); // 같은 km = 캐시 안 씀
const live = liveMilestone("p1");
console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, again: WRITES.n,
route: live.routes[0].legs, base: live.bases[0].legs, path: segmentPath("g"),
routeLines: routePaths("g"), baseLines: routePaths("b1"),
start: await findSegmentKm("p1", "start") }}));"""
got = _node([CALC, LAYOUT, STATE], script, STATE_STUBS)
assert got["a"] == "g → 병곡리 10.5 km" and got["b"] == "병곡리 → 시점 10.5 km"
(url, body), (img, payload), (url2, body2) = got["calls"][:3]
assert url.endswith("/directions") and body == {
"origin": {"lon": 127.9, "lat": 35.68},
"destination": {"lon": 127.85, "lat": 35.66},
}
assert img.endswith("/image/auto?route=g") # 거래처 쪽 구간 = 자동 그림
assert payload["lines"] == [[[35.68, 127.9], [35.66, 127.85]]]
assert [m["name"] for m in payload["marks"]] == ["g", "병곡리"] # 두 끝만(시점 없음)
assert body2["origin"] == {"lon": 127.85, "lat": 35.66}
assert body2["destination"] == {"lon": 127.83, "lat": 35.64}
assert not any(
"/image/auto" in c[0] for c in got["calls"][got["afterRoute"] :]
) # 기준점 = 그림 없음
assert got["route"][0]["km"] == 23.8 and got["route"][0]["auto_km"] == 10.5 # 사용자 값 그대로
assert got["base"][0]["auto_km"] == 10.5 and got["again"] == got["writes"]
assert got["routeLines"] == [got["path"]] and got["baseLines"] == [
got["path"]
] # 구간마다 길 선
assert got["start"] == "" # 시점 → 중점 = 길찾기 없음
def test_화면_등록_모달_연결선_모달():
modal = _read(B08 + "UI_Milestone_Modal.ts")
place = _read(B08 + "UI_Milestone_Place.ts")
leg = _read(B08 + "UI_Milestone_LegModal.ts")
diagram = _read(B08 + "UI_Milestone_Diagram.ts")
css = _read(B08 + "UI_Milestone.css")
# ① 큰 창 · 2열 · 분류 · 항목 목록 · 소재지 · 직접 입력 없음 · 다음 지도
assert 'dialogClass: "b08-milestone__modal"' in modal
assert ".ui-modal.b08-milestone__modal {\n width: min(960px, 94vw);" in css
assert "grid-template-columns: 1fr 1fr;" in css
assert "children: [sort.root, first.item.root, name.root, first.dest.root]" in modal
assert (
'{ value: "base", text: "기준위치" }' in modal
and '{ value: "route", text: "거래처" }' in modal
)
assert 'route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"]' in modal
assert "소재지" in modal and "거래처 위치" not in modal
for text in (modal, place):
assert 'label: "직접 입력"' not in text and "buildAddressField" not in text
assert "editMap(projectId" in place and "onClick: (lon, lat) => void moveTo(lon, lat)" in place
assert "/quantity/milestone/reverse?lon=" in place
# ② 상자 누름 = 같은 등록 모달 · [기준점 +] 없음
assert "base: (id) => openPlaceModal(projectId, { add: false, id })," in diagram
assert 'label: "기준점 +"' not in diagram and "addBase" not in diagram
# ③ 연결선 누름 = 연결선 모달 · 시점 → 중점 = 손 입력만
assert "openLegInfo(projectId, ln.from);" in diagram and "b08-milestone__line-hit" in diagram
assert "clearTimeout(wait);" in diagram # 선 글 더블클릭 = 바로 고침 그대로
mid = leg[leg.index("function openMidLeg") : leg.index("export function openLegInfo")]
assert "길찾기" not in mid.replace("길찾기 없음", "") and "editMap" not in mid
assert 'if (from === "start") return openMidLeg(projectId);' in leg
assert "void reverseAddress(projectId, lon, lat)" in leg and "void run();" in leg
assert "if (!manual.input.checked && !segmentPath(from).length && seg.from && seg.to)" in leg
for rel in (B08 + "UI_Milestone_Modal.ts", B08 + "UI_Milestone_Place.ts",
B08 + "UI_Milestone_LegModal.ts", KMAP, STATE, LAYOUT, CALC,
B08 + "UI_Milestone.ts", B08 + "UI_Milestone_Diagram.ts",
"B08_Quantity/B08_Quantity_Router_Milestone.py"): # fmt: skip
assert len(_read(rel).splitlines()) <= 700, rel