- 새 파일 = 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
311 lines
16 KiB
Python
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
|