"""244-1 B08 거리이정표 — 표 합 거울(파이썬 · TS) · 저장 왕복(임시 storage) · 그림 · 하위 탭 추가 · 삭제 · 중점 기본. 견본 = 실무 「거리이정표」 시트(거창군청 → 병곡리 968-31 → 시점 · 김천역 → 임도중점) 장비 = 2차 23.8 + 1차 0.6 + 비포장 0.8 → 포장 24.4 · 비포장 0.8 · 합계 25.2. """ 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 compute_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) def _leg(road, km): return {"road": road, "km": km} SAMPLE = { "site": {"mid_station_m": None, "mid_road": "비포장"}, "base": {"enabled": True, "name": "병곡리 968-31", "address": "", "dir": "E", "legs": [_leg("1차포장", 0.6), _leg("비포장", 0.8)]}, "routes": [ {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": "W", "legs": [_leg("2차포장", 23.8)], "rows": [{"item": "장비(20톤)", "dest": "start", "note": ""}, {"item": "장비(8톤)", "dest": "start", "note": "같은 길"}]}, {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": "N", "legs": [_leg("2차포장", 61.2), _leg("1차포장", 2.05), _leg("비포장", None)], "rows": [{"item": "철근", "dest": "mid", "note": ""}]}, ], } # fmt: skip def _run_ts(data, mid_default): calc = ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Calc.ts" body = ( f"\nconst d = {json.dumps(data, ensure_ascii=False)};" f"\nconsole.log(JSON.stringify(computeMilestone(d, {json.dumps(mid_default)})));" ) script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", 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 test_견본_표_합(): rows = compute_milestone(SAMPLE, 1500.0) a, b, c = rows assert (a["s2"], a["s1"], a["un"]) == (23.8, 0.6, 0.8) assert (a["paved"], a["unpaved"], a["total"]) == (24.4, 0.8, 25.2) assert b["item"] == "장비(8톤)" and b["total"] == 25.2 and b["company"] == "거창군청" # 기준점 안 거침 = 거래처 → 시점 = 포장(152 · 옛 2차 · 1차 값도 자리로) · 목적지 중점 = 시점 → 중점 # (기본 1500 ÷ 1000) 비포장 더함 · 빈 km = 0 assert (c["s2"], c["s1"], c["pv"], c["un"], c["total"]) == (0, 0, 63.25, 1.5, 64.75) # 사용자 중점 측점 · 기준점 없음 = 공통 구간 안 셈 no_base = {**SAMPLE, "base": {**SAMPLE["base"], "enabled": False}, "site": {"mid_station_m": 420, "mid_road": "1차포장"}} # fmt: skip a2, _, c2 = compute_milestone(no_base, 1500.0) # 기준점 없음 = 2차 · 1차 없이 포장(152) · 시점 → 중점 1차포장 값 = 포장 assert a2["total"] == 23.8 and (a2["s2"], a2["pv"]) == (0, 23.8) assert (c2["s1"], c2["pv"], c2["un"]) == (0, 63.67, 0) def test_거울_TS(): for mid in (1500.0, None): assert _run_ts(SAMPLE, mid) == compute_milestone(SAMPLE, mid) odd = {**SAMPLE, "site": {"mid_station_m": 1234.5678, "mid_road": "비포장"}} assert _run_ts(odd, 10.0) == compute_milestone(odd, 10.0) def _route_csv(root: Path, points): path = root / "B05_Profile" / "route" / "planned_route.csv" path.parent.mkdir(parents=True) lines = ["sequence,x,y"] + [f"{i},{x},{y}" for i, (x, y) in enumerate(points)] path.write_text("\n".join(lines), encoding="utf-8") def test_중점_기본_노선_절반(tmp_path): assert ms.site_info(tmp_path, None)["mid_default_m"] is None # 노선 전 = 빈칸 _route_csv(tmp_path, [(208000, 367000), (209000, 367000), (209000, 367500)]) site = ms.site_info(tmp_path, None) assert site["length_m"] == 1500 and site["mid_default_m"] == 750 lon, lat = site["start_lonlat"] assert 124 < lon < 132 and 33 < lat < 39 # 작업 좌표계(폴백 5186) → 경위도 assert site["mid_lonlat"] != site["start_lonlat"] def _client(tmp_path, monkeypatch): from fastapi import FastAPI from fastapi.testclient import TestClient async def root(_project_id): return tmp_path monkeypatch.setattr(ms, "_project_root", root) app = FastAPI() app.include_router(ms.router) return TestClient(app), f"/api/projects/{uuid4()}/quantity/milestone" def test_저장_왕복_그림(tmp_path, monkeypatch): client, base = _client(tmp_path, monkeypatch) empty = client.get(base).json() assert empty["data"]["routes"] == [] and empty["table"] == [] and empty["images"] == {} _route_csv(tmp_path, [(0, 0), (3000, 0)]) saved = client.put(base, json=SAMPLE).json() assert saved["data"] == ms.Milestone.model_validate(SAMPLE).model_dump() assert saved["table"] == compute_milestone(SAMPLE, 1500.0) # 서버가 다시 셈 stored = json.loads((tmp_path / "B08_Quantity/milestone/milestone.json").read_text("utf-8")) assert stored["routes"][0]["name"] == "거창군청" and "table" not in stored assert client.get(base).json() == saved # 그림 — 경로 하나 = 그림 하나(교체) · 내려받기 · 지우기 img = base + "/image" up = client.post(img, params={"route": "r1"}, files={"file": ("a.PNG", b"png1", "image/png")}) assert up.json() == {"name": "r1.png"} client.post(img, params={"route": "r1"}, files={"file": ("b.jpg", b"jpg2", "image/jpeg")}) assert client.get(base).json()["images"] == {"r1": "r1.jpg"} assert client.get(img, params={"route": "r1"}).content == b"jpg2" assert ( client.post(img, params={"route": "r1"}, files={"file": ("x.pdf", b"1")}).status_code == 400 ) assert client.get(img, params={"route": "../x"}).status_code == 400 client.post(img, params={"route": "r2"}, files={"file": ("c.png", b"png3", "image/png")}) client.delete(img, params={"route": "r2"}) assert client.get(img, params={"route": "r2"}).status_code == 404 # 경로 삭제 뒤 [저장] = 그 경로 그림도 지움 client.post(img, params={"route": "r2"}, files={"file": ("c.png", b"png3", "image/png")}) one = {**SAMPLE, "routes": SAMPLE["routes"][:1]} assert client.put(base, json=one).json()["images"] == {"r1": "r1.jpg"} # 겹친 id · 나쁜 값 = 거절 twice = {**SAMPLE, "routes": [SAMPLE["routes"][0]] * 2} assert client.put(base, json=twice).status_code == 422 bad = {**SAMPLE, "site": {"mid_station_m": -1, "mid_road": "비포장"}} assert client.put(base, json=bad).status_code == 422 def _read(rel): return (ROOT / rel).read_text(encoding="utf-8") def test_메인_탭_하위_탭_추가_삭제(): page = _read("B08_Quantity/B08_Quantity_UI_Page.ts") assert '{ id: "milestone", label: "B08_Quantity_Tab_Milestone" }' in page assert 'if (tab === "milestone" && projectId) return renderMilestone(view, projectId);' in page assert 'B08_Quantity_Tab_Milestone: ["거리이정표"' in _read( "ui_template/ui_template_locale_b4.ts" ) ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") assert 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui # 244-4 — 하위 탭 줄 [+ 추가] · [삭제] 없음(탭은 [+] 등록 · 항목 정보 [삭제]로만) assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert "fresh.routes = fresh.routes.filter((r) => r.id !== route!.id);" in modal assert "editOnDouble: true" in ui and "milestoneTable(data, siteInfo()" in ui assert "computeMilestone(data, midDefaultM)" in _read( "B08_Quantity/B08_Quantity_Milestone_Table.ts" ) route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state assert state.count('method: "PUT"') == 1 and "setTimeout" not in state options = _read("B08_Quantity/B08_Quantity_UI_Options.ts") assert 'card("milestone"' not in options and "milestoneCardBody" not in options # 244-6 assert "await saveMilestone(projectId);" in options registry = _read("A00_Common/b_page_state.ts") assert '"b08-milestone": { bucket: "draft", scope: "project" }' in registry main = _read("main.py") assert "app.include_router(b08_milestone_router, dependencies=protected_with_company)" in main # 짝 표기 assert "짝 = `B08_Quantity_Milestone_Calc.ts`" in _read( "B08_Quantity/B08_Quantity_Milestone_Calc.py" ) assert "짝 = `B08_Quantity_Milestone_Calc.py`" in _read( "B08_Quantity/B08_Quantity_Milestone_Calc.ts" ) for name in ("UI_Milestone.ts", "UI_Milestone_Route.ts", "UI_Milestone_State.ts", "Router_Milestone.py"): # fmt: skip rel = f"B08_Quantity/B08_Quantity_{name}" assert len(_read(rel).splitlines()) <= 700