feat(B08): 거리이정표 등록 모달 2열 · 소재지 다음 지도 · 연결선 모달 길찾기 · 도로 구분 갈래 열(244-13)

- 등록 모달 한 벌(추가 · 상자 고침 · 삭제) — 분류(기준위치 · 거래처) · 항목 고르기 · 거래처 이름 · 목적지 2열 · 큰 창
- 소재지 = 주소 찾기 · 다음 지도 누름 · 표지 끌기(직접 입력 없음) · 좌표 → VWorld 주소 서버 길
- 다이어그램 [기준점 +] 없앰 · 연결선 누름 = 도로 구분 · 거리 · 다음 지도 경로 · 끝점 끌기 = 다시 길찾기
- 시점 → 중점 = 손 입력만 · 자동 그림 = 거래처 쪽 구간
- 도로 구분 = 기준점 거침 2차 · 1차 · 비포장 · 그 밖 포장 · 비포장(파이썬 · TS 짝) · 표 열 기준점 따라

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 23:36:39 +09:00
co-authored by Claude Opus 5.5
parent 1173d237dd
commit 2afb1af1ca
21 changed files with 1315 additions and 499 deletions
@@ -260,7 +260,9 @@ def test_화면_브라우저_캡처_없음_길찾기_뒤_자동():
assert "getDisplayMedia" not in text and "지도 캡처" not in text, name
assert "캡처는 localhost" not in text, name
state = read("B08_Quantity_UI_Milestone_State.ts")
assert "if (lines.length) void autoRouteImage(projectId, routeId, lines);" in state
# 244-13 — 연결선 한 구간 [길찾기] · 거래처 쪽 구간만 자동 그림
assert 'if (image && seg.key === "route" && paths.has(from))' in state
assert "void autoRouteImage(projectId, from, routePaths(from));" in state
assert "JSON.stringify({ lines, marks })" in state and "/image/auto?route=" in state
assert "user_only=true" in state and "showToast(reason," in state
route = read("B08_Quantity_UI_Milestone_Route.ts")
@@ -0,0 +1,305 @@
"""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 compute_milestone # noqa: E402
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_도로_구분_갈래_연결선_구간_끝():
d = json.dumps(WITH_BASE)
off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}})
got = _node(
[CALC, LAYOUT],
f"""const D = {d}; const O = {off}; const S = [127.83, 35.64];
console.log(JSON.stringify([roadsFor(true), roadsFor(false),
["g", "d", "base", "start"].map((f) => chainVia(D, f)), ["g", "base"].map((f) => chainVia(O, f)),
segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "base", S),
segmentEnds(D, "start", S), segmentEnds(O, "base", S), segmentEnds(O, "g", S),
kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""",
)
assert got[0] == ["2차포장", "1차포장", "비포장"] and got[1] == ["포장", "비포장"]
assert got[2] == [True, False, True, False] and got[3] == [False, False]
assert got[4] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68],
"to": [127.85, 35.66]} # fmt: skip
assert got[5]["text"] == "d → 시점" and got[5]["to"] == [127.83, 35.64]
assert got[6] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66],
"to": [127.83, 35.64]} # fmt: skip
assert got[7] is None and got[8] is None # 시점 → 중점 = 손 입력 · 기준점 없음
assert got[9]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로
assert got[10] == ["route", "base"] and got[11] == ["route"]
def test_표_열_기준점_따라():
got = _node(
[B08 + "UI_Milestone.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", "base");
const writes = WRITES.n;
await findSegmentKm("p1", "base"); // 같은 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.base.legs, path: segmentPath("g"),
routeLines: routePaths("g"), baseLines: routePaths("base"),
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: () => openPlaceModal(projectId, { add: false, id: "base" }),' 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 (!segmentPath(from).length && seg.from && seg.to) void run(false);" 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
+2 -2
View File
@@ -162,8 +162,8 @@ def test_메인_탭_하위_탭_추가_삭제():
# 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 !== routeId);" in modal
assert "editOnDouble: true" in ui and "computeMilestone(liveMilestone(projectId)" in ui
assert "fresh.routes = fresh.routes.filter((r) => r.id !== route!.id);" in modal
assert "editOnDouble: true" in ui and "computeMilestone(data, siteInfo()" in ui
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route
# 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음
@@ -141,13 +141,16 @@ def test_빈_방위_차례_같은_방위_나란히():
def test_화면_상자_누름_더하기_창():
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts").read_text(encoding="utf-8")
# 244-4 — 상자 누름 = 모달(탭 이동 아님)
assert "openTab" not in ui and "route: (id) => openRouteInfo(projectId, id)," in ui
assert (
"openTab" not in ui
and "route: (id) => openPlaceModal(projectId, { add: false, id })," in ui
)
assert 'if (box.kind === "site") g.append(plus(box, false));' in ui
assert 'if (box.kind === "base") g.append(plus(box, true));' in ui
assert "openAddItem(projectId, viaBase, addRoute)" in ui
assert "openPlaceModal(projectId, { add: true, viaBase }, addRoute)" in ui
modal = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts").read_text(encoding="utf-8")
assert "openModal({" in modal
assert "same.rows.push(row)" in modal # 같은 업체 이름 = 그 경로에 표 줄
assert "same.rows.push(...rowValues())" in modal # 같은 업체 이름 = 그 경로에 표 줄
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8")
assert "createDiagram(projectId, (init) => addRoute(projectId, init))" in page
assert "children: [diagram.root, none, table.root]" in page and "diagram.refresh();" in page
@@ -140,30 +140,30 @@ def test_시점_모달_주소_길(tmp_path, monkeypatch):
def test_화면_모달_팬줌_그림탭():
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
# ① [+] 모달 — 항목 · 업체 이름 · 목적지 + 주소 검색(B01 재활용) · 방위 고르기 없음 · [등록]
# ① [+] 모달 — 항목 · 거래처 이름 · 목적지 + 소재지(244-13 · 주소 찾기 = B01 후보 검색 재활용 ·
# 지도 = 다음 지도) · 방위 고르기 없음 · [등록]
add = modal[
modal.index("export function openAddItem") : modal.index("export function openRouteInfo")
modal.index("export function openPlaceModal") : modal.index("export interface SiteAddress")
]
for text in ('label: "항목"', 'label: "거래처 이름"', 'label: "목적지"', 'label: "등록"',
"buildAddressField(null)", "placeOf(address,"): # fmt: skip
for text in ('label: "항목"', 'label: "목적지"'): # 항목 · 목적지 줄 = rowField
assert text in modal
for text in ('label: "거래처 이름"', '"등록"', "placePicker(", "place.value()"):
assert text in add
assert "방위" not in add.replace("방위는 이 자리로 자동", "") and "dirOptions" not in modal
assert 'from "../B01_Dashboard/B01_Dashboard_UI_AddressField"' in modal
assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal
field = _read("B01_Dashboard/B01_Dashboard_UI_AddressField.ts")
assert "point: () => picked," in field and "searchAddress(text)" in field
place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts")
assert 'from "../B01_Dashboard/B01_Dashboard_Api_Fetch"' in place
assert "searchAddress(text)" in place
assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal # 공사지 모달 지도
# ② 시점 모달 — 등록 주소 · VWorld 주소 · 지도
assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal
assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal
# ⑤ 업체 상자 = 항목 정보(구간 km · 도로 구분 · 고침 · 삭제)
info = modal[
modal.index("export function openRouteInfo") : modal.index("export function openBaseInfo")
]
for text in ('label: "고침"', 'label: "삭제"', 'legsEditor(route.legs)',
"rowsEditor(route.rows)", "putMilestone(projectId, fresh)"): # fmt: skip
assert text in info
# ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달
for text in ('"고침"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"):
assert text in add
leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts")
assert "legsEditor(legsOf(), via)" in leg
diagram = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert "route: (id) => openRouteInfo(projectId, id)," in diagram
assert "route: (id) => openPlaceModal(projectId, { add: false, id })," in diagram
assert "start: () => openSiteInfo(projectId)," in diagram
# ③ 팬 · 줌 = 공용
assert 'from "../A00_Common/b_svg_zoom_pan"' in diagram and "attachSvgZoomPan({" in diagram
@@ -55,8 +55,10 @@ def test_좌측_패널_방위_칸_없음_모달_안내():
assert "공사지에서 본 방위" not in state and "dirOptions" not in state
assert "d.base.dir" not in state # 244-6 — 기준점 칸은 다이어그램 기준점 모달로
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")]
assert "주소를 고르면 다이어그램에 실제 방위로 놓임" in base
base = modal[
modal.index("export function openPlaceModal") : modal.index("interface SiteAddress")
]
assert "방위는 이 자리로 자동" in base # 244-13 — 기준점 = 등록 모달 분류 기준위치
layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")
assert (
"placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270" in layout
@@ -118,11 +118,13 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음():
site = modal[modal.index("export function openSiteInfo") :]
assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site
assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site
base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")]
assert "enabled: true," in base and 'label: "기준점 없앰"' in base
assert "fresh.base.enabled = false;" in base
assert 'label: "기준점 +"' in ui and "openBaseInfo(projectId, true)" in ui
assert "addBase.hidden = data.base.enabled;" in ui
# 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음
base = modal[
modal.index("export function openPlaceModal") : modal.index("interface SiteAddress")
]
assert "enabled: true," in base and 'label: "삭제"' in base
assert "fresh.base = { enabled: false," in base
assert 'label: "기준점 +"' not in ui and "openBaseInfo" not in ui and "addBase" not in ui
assert "content: panContent(vw, vh)" in ui and "panStart," in ui
@@ -219,8 +219,10 @@ def test_화면_길찾기_단추_키_화면에_없음():
modal = read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
state = read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
table = read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert 'label: "길찾기"' in modal and "routeKm(projectId, id)" in modal # 단추 · [등록] 뒤
assert "/quantity/milestone/directions" in state and "kmSegments(" in state
# 244-13 — [길찾기] = 연결선 모달 단추 · [등록] 뒤 그 구간 한 번
leg = read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts")
assert 'label: "길찾기"' in leg and "findSegmentKm(projectId, segment)" in modal
assert "/quantity/milestone/directions" in state and "segmentEnds(" in state
assert '"도로 구분 고르기"' in table and "r.pick" in table
assert "legs: [{ road: null, km: null }]" in table # 새 거래처 = 도로 구분 안 고름
# 키는 서버만 — 화면 파일에 키 이름 · KakaoAK 없음
@@ -215,13 +215,13 @@ def test_화면_자리_캡처_키_서버만():
kmap = _read(KMAP)
assert "sdk.js" in kmap and "?appkey=${encodeURIComponent(key)}&autoload=false" in kmap
assert "getDisplayMedia" not in kmap and "[지도 캡처]" not in kmap # 244-11 = 서버 자동
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
assert "mapView.root" in modal and "mapView.draw();" in modal
leg = _read("B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts") # 244-13 = 연결선 모달 지도
assert "map.root," in leg and "map.set(pins()" in leg
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
assert "routeMapView(projectId, routeId)" in route
assert "mapView.draw();" in route
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "paths.set(routeId, lines);" in state # 메모리만(캐시 · 서버 안 감)
assert "paths.set(from, res.path!);" in state # 메모리만(캐시 · 서버 안 감)
for rel in Path(ROOT / "B08_Quantity").glob("*.ts"): # 키 이름은 서버만
text = rel.read_text(encoding="utf-8")
assert "KAKAO_JS_KEY" not in text and "KAKAO_REST_API_KEY" not in text, rel