"""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 = "ui_template/ui_template_kakao_map.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 API_BASE_URL = ""; (globalThis as any).fetch = async () => ({ ok: true, json: 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({ 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(); 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("p1", "g"), routeLines: [segmentPath("p1", "g")], baseLines: [segmentPath("p1", "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["route"][0]["path"] == [[35.68, 127.9], [35.66, 127.85]] # 373 길 선 = 구간 자료에 assert got["base"][0]["path"] == 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({" 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 "!manual.input.checked && !segmentPath(projectId, from).length" 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