merge: dev(254-1 여러 기준점) 합침 — 장소 그림 상자 id = bases id (256-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
2026-10-11 14:04:14 +09:00
co-authored by Claude Opus 5.5
22 changed files with 713 additions and 268 deletions
@@ -16,6 +16,7 @@ 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 migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
LONG = "경상남도 거창군 북상면 병곡리 968-31"
@@ -58,6 +59,7 @@ def _clipped(note, box_w):
def _lay(data, site=None):
data = migrate_milestone(data) # 254 옛 꼴 → bases · to(옛 기준점 = b1)
return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)")
@@ -21,7 +21,10 @@ 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
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_"
@@ -90,29 +93,29 @@ def test_포장_도로_구분_거울():
def test_도로_구분_갈래_연결선_구간_끝():
d = json.dumps(WITH_BASE)
off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}})
# 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", "base", "start"].map((f) => PAVED[segKind(D, f)]),
["g", "base"].map((f) => segKind(O, f)),
["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),
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", "base"]
assert got[2] == [True, False, True, False] and got[3] == [False, False]
assert got[4] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68],
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[5]["text"] == "d → 시점" and got[5]["to"] == [127.83, 35.64]
assert got[6] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66],
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[7] is None and got[8] is None # 시점 → 중점 = 손 입력 · 기준점 없음
assert got[9]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로
assert got[10] == ["route", "base"] and got[11] == ["route"]
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_표_열_기준점_따라():
@@ -237,13 +240,13 @@ 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 b = await findSegmentKm("p1", "b1"); // 254 옛 꼴 캐시 → 기준점 id b1
const writes = WRITES.n;
await findSegmentKm("p1", "base"); // 같은 km = 캐시 안 씀
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.base.legs, path: segmentPath("g"),
routeLines: routePaths("g"), baseLines: routePaths("base"),
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"
@@ -290,7 +293,7 @@ def test_화면_등록_모달_연결선_모달():
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 "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
@@ -14,10 +14,13 @@ ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
def _layout(data, site=None, mid_km=0.75):
data = migrate_milestone(data) # 254 옛 꼴(base · via_base) → bases · to(옛 기준점 = b1)
src = (ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Layout.ts").read_text(encoding="utf-8")
body = (
f"\nconst d = {json.dumps(data, ensure_ascii=False)};"
@@ -95,17 +98,17 @@ def test_기준점_기준_방위_선_글():
), # fmt: skip
)
cx, cy = _center(lay, "site")
bx, by = _center(lay, "base")
bx, by = _center(lay, "b1")
assert bx < cx - 200 and abs(by - cy) < 1 # 기준점 = 공사지 서쪽
gx, gy = _center(lay, "g")
assert abs(gx - bx) < 1 and gy < by - 200 # 거창군청 = 기준점 북쪽
kx, _ = _center(lay, "k")
assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽
labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]}
assert labels[("g", "base")] == ["2차포장 23.8km"]
assert labels[("base", "start")] == ["1차포장 0.6km", "비포장 0.8km"]
assert labels[("g", "b1")] == ["2차포장 23.8km"]
assert labels[("b1", "start")] == ["1차포장 0.6km", "비포장 0.8km"]
assert labels[("k", "start")] == []
assert next(b for b in lay["boxes"] if b["id"] == "base")["label"] == "병곡리 968-31"
assert next(b for b in lay["boxes"] if b["id"] == "b1")["label"] == "병곡리 968-31"
def test_경위도_있으면_방위각이_8방보다_앞섬():
@@ -18,6 +18,7 @@ if str(ROOT) not in sys.path:
from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
@@ -58,8 +59,9 @@ def test_방위_좌표_자동():
# 기준점 좌표 없음 → 시점 좌표 기준 · 업체 = 시점 남쪽 · 기준점 안 거침 업체 = 동쪽
"routes": [_route("s", 127.8, 35.6), _route("e", 127.9, 35.7, via=False)],
}
lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "base")
# 254 옛 꼴 → bases · to(옛 기준점 = b1)
lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "b1")
sx, sy = _center(lay, "s")
assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래
cx, cy = _center(lay, "site")
@@ -68,8 +70,9 @@ def test_방위_좌표_자동():
# 기준점 좌표 있음 → 기준점 기준(업체가 기준점 북쪽)
data["base"] = {**base, "lon": 127.7, "lat": 35.7}
data["routes"] = [_route("n", 127.7, 35.8)]
lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "base")
# 254 옛 꼴 → bases · to(옛 기준점 = b1)
lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)")
bx, by = _center(lay, "b1")
nx, ny = _center(lay, "n")
assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표)
assert abs(nx - bx) < 1 and ny < by - 200
@@ -188,17 +191,17 @@ def test_선_글_바로_고침_모달과_한_벌():
"legs": [{"road": "1차포장", "km": 0.6}]},
"routes": [{**_route("g"), "legs": [{"road": "2차포장", "km": None}]}],
} # fmt: skip
d = json.dumps(data)
d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1)
got = _node(
f"[legAt({d}, 'g', 0, 0.75), legAt({d}, 'start', 0, 0.75), legAt({d}, 'x', 0, 0),"
f" setLeg(setLeg(setLeg({d}, 'g', 0, {{road: '1차포장', km: 23.8}}),"
f" 'base', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]"
f" 'b1', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]"
)
assert got[0] == {"road": "2차포장", "km": None}
assert got[1] == {"road": "비포장", "km": 0.75} and got[2] is None
after = got[3]
assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}]
assert after["base"]["legs"] == [{"road": "1차포장", "km": None}]
assert after["bases"][0]["legs"] == [{"road": "1차포장", "km": None}]
assert after["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"}
# 표 셈 — 포장 종류 = 선 자리(152 · 기준점 거침 거래처 = 2차포장 · 저장 값 1차포장 이어도)
(row,) = compute_milestone(after, 1500.0)
@@ -5,9 +5,15 @@
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
@@ -17,6 +23,7 @@ def _read(rel):
def _layout(base, site):
data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, "base": base, "routes": []}
data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1)
src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts"))
expr = f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)"
result = subprocess.run(
@@ -27,7 +34,7 @@ def _layout(base, site):
assert result.returncode == 0, result.stderr
lay = json.loads(result.stdout)
center = {b["id"]: (b["x"] + b["w"] / 2, b["y"] + b["h"] / 2) for b in lay["boxes"]}
return center["site"], center["base"]
return center["site"], center["b1"]
BASE = {"enabled": True, "name": "기준", "address": "", "legs": []}
@@ -60,6 +67,5 @@ def test_좌측_패널_방위_칸_없음_모달_안내():
]
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
)
assert "placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ??" in layout
assert '(anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor))' in layout
@@ -82,13 +82,13 @@ def test_실제_포인터_사건으로_팬():
def test_거래처_상자_항목_글():
data = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []},
"bases": [],
"routes": [
{"id": "g", "name": "거창군청", "address": "", "via_base": True, "dir": None,
{"id": "g", "name": "거창군청", "address": "", "to": "site", "dir": None,
"legs": [], "rows": [{"item": "장비", "dest": "start", "note": ""},
{"item": " 장비 ", "dest": "mid", "note": ""},
{"item": "골재", "dest": "mid", "note": ""}]},
{"id": "k", "name": "", "address": "", "via_base": True, "dir": None,
{"id": "k", "name": "", "address": "", "to": "site", "dir": None,
"legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]},
],
} # fmt: skip
@@ -118,12 +118,12 @@ 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
# 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음
# 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 254 여럿 · 삭제 = 그 기준점만
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 "fresh.bases.push({" in base and 'label: "삭제"' in base
assert "fresh.bases = fresh.bases.filter((b) => b.id !== base.id);" 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
@@ -24,7 +24,10 @@ 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
from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402
compute_milestone,
migrate_milestone,
)
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
FAKE_KEY = "fake-kakao-key-0123456789"
@@ -144,7 +147,8 @@ def test_키_값_응답_로그에_안_나옴(monkeypatch, caplog):
def _data(base_enabled=True, base_ll=(127.85, 35.66)):
return {
"""254 옛 꼴로 적고 새 꼴(bases · to · 옛 기준점 = b1)로 옮겨 씀."""
return migrate_milestone({
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": base_enabled, "name": "병곡리 968-31", "address": "", "dir": None,
"lon": base_ll[0], "lat": base_ll[1], "legs": []},
@@ -156,7 +160,7 @@ def _data(base_enabled=True, base_ll=(127.85, 35.66)):
"lon": None, "lat": None, "legs": [{"road": None, "km": None}],
"rows": [{"item": "철근", "dest": "start", "note": ""}]},
],
} # fmt: skip
}) # fmt: skip
LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts"
@@ -179,27 +183,27 @@ def test_구간_채움():
two = _node(LAYOUT, f"kmSegments({d}, 'r2', null)")
assert two == [{"key": "route", "text": "김천역 → 시점", "from": None, "to": None}]
# 자동 km 넣기 — 빈 구간 = 안 고른 줄 하나 · 사용자 km 는 그대로
put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 'route', 10.5), 'r1', 'base', 1.4)")
put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 10.5), 'b1', 1.4)")
assert put["routes"][0]["legs"] == [{"road": "2차포장", "km": 23.8, "auto_km": 10.5}]
assert put["base"]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}]
assert put["bases"][0]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}]
# 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값
lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)")
line = {ln["from"]: ln for ln in lay["lines"]}
assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"]
assert line["base"]["labels"] == ["도로 구분 고르기 1.4km"] and line["base"]["tips"] == [""]
assert line["b1"]["labels"] == ["도로 구분 고르기 1.4km"] and line["b1"]["tips"] == [""]
assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로
def test_사용자_값_우선_고르기_표시():
data = _data()
data["routes"][0]["legs"] = [{"road": "2차포장", "km": None, "auto_km": 10.5}]
data["base"]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}]
data["bases"][0]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}]
a, b = compute_milestone(data, 1500.0)
# 거래처 = 자동 10.5 · 기준점 = 사용자 0.6(자동 1.4 무시) · 중점 1.5 비포장
assert (a["s2"], a["s1"], a["un"], a["total"], a["pick"]) == (10.5, 0.6, 1.5, 12.6, False)
# 152 도로 구분 안 고름 = 선 자리 포장 종류로 셈(pick 늘 거짓) — 빈 km = 0
assert (b["total"], b["pick"]) == (0, False)
data["base"]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리)
data["bases"][0]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리)
a2, _ = compute_milestone(data, 1500.0)
assert (a2["s1"], a2["total"], a2["pick"]) == (0.6, 12.6, False)
# TS 짝 거울
@@ -210,7 +214,7 @@ def test_사용자_값_우선_고르기_표시():
# 저장 꼴 — auto_km · road null 받음 · 빠진 road = 옛 기본 2차포장
saved = ms.Milestone.model_validate(data).model_dump()
assert saved["routes"][0]["legs"][0]["auto_km"] == 10.5
assert saved["base"]["legs"][0]["road"] is None
assert saved["bases"][0]["legs"][0]["road"] is None
assert ms.Leg.model_validate({"km": 1}).road == "2차포장"
@@ -19,6 +19,7 @@ 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 migrate_milestone # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone"
@@ -118,14 +119,14 @@ def test_표지_셋():
"routes": [{"id": "r1", "name": "거창군청", "lon": 127.9, "lat": 35.68, "via_base": True},
{"id": "r2", "name": "", "lon": None, "lat": None, "via_base": False}],
} # fmt: skip
d = json.dumps(data)
d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1)
got = _node(
[LAYOUT],
f"""const D = {d};
console.log(JSON.stringify([
mapMarks(D, "r1", [127.83, 35.64]),
mapMarks(D, "r2", [127.83, 35.64]),
mapMarks({{ ...D, base: {{ ...D.base, enabled: false }} }}, "r1", null),
mapMarks({{ ...D, bases: [] }}, "r1", null),
]));""",
)
assert got[0] == [
@@ -9,7 +9,11 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, road_of # noqa: E402
from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402
compute_milestone,
migrate_milestone,
road_of,
)
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
@@ -87,7 +91,9 @@ def test_153_지도_보기_유지_상자_색_끌어_옮김():
assert var in block[: block.index("}")], kind
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert 'if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift);' in ui
assert 'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui # 159 이름
assert (
'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui
) # 159 이름
def test_153_끌어_놓은_자리_레이아웃():
@@ -96,23 +102,24 @@ def test_153_끌어_놓은_자리_레이아웃():
"base": {"enabled": True, "name": "기준", "address": "", "dir": "W", "legs": [], "pos": [500, -40]},
"routes": [{"id": "r", "name": "a", "address": "", "via_base": False, "dir": "N", "legs": [],
"rows": [], "pos": [-700, 300]}]} # fmt: skip
expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'base' || b.id === 'r').map((b) => [b.id, b.x, b.y])"
data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1)
expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'b1' || b.id === 'r').map((b) => [b.id, b.x, b.y])"
out = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript",
"-e", src + f"\nconsole.log(JSON.stringify({expr}));"],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert out.returncode == 0, out.stderr
assert sorted(json.loads(out.stdout)) == [["base", 500, -40], ["r", -700, 300]]
assert sorted(json.loads(out.stdout)) == [["b1", 500, -40], ["r", -700, 300]]
def test_154_연결_점_끌어_잇기():
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts")
assert 'if (box.kind === "route") g.append(connectDot(box, shift));' in ui
assert 'const via = t === "base";' in ui and "route.via_base = via;" in ui
assert "route.to = t;" in ui # 254 놓은 기준점 id · 공사지 = "site"
assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움
assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번
assert "if (via === now) return;" in ui # 같은 연결 = 그대로
assert "if (route.linked !== false && route.to === t) return;" in ui # 같은 연결 = 그대로
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
assert (
'createCheckbox("기준점 거침")' not in modal and "via_base: via.input.checked" not in modal
@@ -145,7 +152,7 @@ def test_155_요소_추가_안_이은_거래처_수기_입력():
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "if (owner?.manual) return" in state # 수기 입력 = 카카오 길찾기 안 함
layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")
assert "if (!route || route.linked === false) return null;" in layout
assert "if (!owner || owner.linked === false) return null;" in layout # 254 거래처 · 기준점
def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화():
@@ -0,0 +1,213 @@
"""254-1 B08 거리이정표 여러 기준점(사용자 162-10) — `bases[]` · 경로 `to` · 옛 꼴 옮김 ·
기준점 사슬 셈(파이썬 · TS 거울) · 돌기 막음 ·
도로 구분(시점에 닿는 기준점 구간 = 1차 · 그 밖 = 2차).
견본 사슬 = 거창군청 → 기준점 A(병곡리) → 기준점 B(신원) → 시점 · 김천역 → B → 시점 ·
철근 상회 → 시점(직접).
"""
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_Haul import with_haul_routes # noqa: E402
from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402
base_chain,
compute_milestone,
migrate_milestone,
)
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
B08 = "B08_Quantity/B08_Quantity_"
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def _node(files, expr):
src = "\n".join(IMPORTS.sub("", _read(f)) for f in files)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src + f"\nconsole.log(JSON.stringify({expr}));"],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _leg(road, km):
return {"road": road, "km": km}
def _base(bid, to, legs, linked=True):
return {"id": bid, "name": bid, "address": "", "dir": None, "legs": legs, "to": to,
"linked": linked} # fmt: skip
def _route(rid, to, legs, dest="start"):
return {"id": rid, "name": rid, "address": "", "dir": None, "to": to, "legs": legs,
"rows": [{"item": "장비", "dest": dest, "note": ""}]} # fmt: skip
CHAIN = {
"site": {"mid_station_m": 800, "mid_road": "비포장"},
"bases": [
_base("A", "B", [_leg(None, 4.5), _leg("비포장", 0.3)]),
_base("B", "site", [_leg(None, 0.6)]),
],
"routes": [
_route("g", "A", [_leg(None, 23.8)]),
_route("k", "B", [_leg(None, 61.2)], dest="mid"),
_route("c", "site", [_leg(None, 2.0)]),
],
}
OLD = {
"site": {"mid_station_m": None, "mid_road": "비포장"},
"base": {"enabled": True, "name": "병곡리", "address": "주소", "dir": "E", "lon": 127.85,
"lat": 35.66, "pos": [10, 20], "legs": [_leg("1차포장", 0.6)]},
"routes": [
{"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None,
"legs": [_leg("2차포장", 23.8)], "rows": [{"item": "장비", "dest": "start", "note": ""}]},
{"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None,
"legs": [_leg(None, 5)], "rows": [{"item": "철근", "dest": "start", "note": ""}]},
],
} # fmt: skip
def test_옛_꼴_옮김_거울():
new = migrate_milestone(OLD)
assert "base" not in new and [b["id"] for b in new["bases"]] == ["b1"]
b1 = new["bases"][0]
assert (b1["to"], b1["linked"], b1["name"], b1["pos"]) == ("site", True, "병곡리", [10, 20])
assert b1["legs"] == [_leg("1차포장", 0.6)] and b1["lon"] == 127.85
assert [(r["id"], r["to"], "via_base" in r) for r in new["routes"]] == [
("r1", "b1", False),
("r2", "site", False),
]
# 꺼진 기준점 = bases 없음 · 거침 경로도 시점으로
off = migrate_milestone({**OLD, "base": {**OLD["base"], "enabled": False}})
assert off["bases"] == [] and [r["to"] for r in off["routes"]] == ["site", "site"]
assert migrate_milestone(new) == new # 새 꼴 = 그대로
# TS 짝 — 옮긴 꼴 · 표 합이 같음
calc = [B08 + "Milestone_Calc.ts"]
ts = _node(calc, f"migrateMilestone({json.dumps(OLD)})")
assert ts["bases"][0]["id"] == "b1" and [r["to"] for r in ts["routes"]] == ["b1", "site"]
assert ts["bases"][0]["legs"] == b1["legs"] and ts["bases"][0]["pos"] == [10, 20]
assert _node(calc, f"computeMilestone({json.dumps(OLD)}, 1500)") == compute_milestone(
OLD, 1500.0
)
# 옛 표 합 그대로 — 2차 23.8 + 1차 0.6
(a, b) = compute_milestone(OLD, 1500.0)
assert (a["s2"], a["s1"], a["total"]) == (23.8, 0.6, 24.4) and (b["pv"], b["total"]) == (5, 5)
def test_서버_읽기_받기_새_꼴(tmp_path, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
# 옛 파일 읽기 = 새 꼴(지우지 않음 · 저장 때 새 꼴)
folder = tmp_path / "B08_Quantity" / "milestone"
folder.mkdir(parents=True)
(folder / "milestone.json").write_text(json.dumps(OLD, ensure_ascii=False), "utf-8")
data = ms.read_data(tmp_path)
assert [b["id"] for b in data["bases"]] == ["b1"] and data["routes"][0]["to"] == "b1"
assert "base" not in data and "via_base" not in data["routes"][0]
async def root(_pid):
return tmp_path
monkeypatch.setattr(ms, "_project_root", root)
app = FastAPI()
app.include_router(ms.router)
client = TestClient(app)
url = f"/api/projects/{uuid4()}/quantity/milestone"
saved = client.put(url, json=OLD).json() # 옛 꼴 받기 = 새 꼴로 저장
stored = json.loads((folder / "milestone.json").read_text("utf-8"))
assert "base" not in stored and stored["bases"][0]["id"] == "b1"
assert stored["routes"][0]["to"] == "b1" and "via_base" not in stored["routes"][0]
assert saved["table"] == compute_milestone(OLD, None)
# 새 꼴 사슬 왕복 · 겹친 id · 공사지 상자 id = 거절
assert client.put(url, json=CHAIN).json()["data"]["bases"][0]["to"] == "B"
twice = {**CHAIN, "bases": [*CHAIN["bases"], _base("g", "site", [])]}
assert client.put(url, json=twice).status_code == 422
box = {**CHAIN, "bases": [_base("site", "site", [])]}
assert client.put(url, json=box).status_code == 422
# 운반 자동 상자 = 시점으로(안 이음)
(auto,) = with_haul_routes({"routes": []}, [{"key": "1", "name": "골재"}])["routes"]
assert auto["to"] == "site" and auto["linked"] is False and "via_base" not in auto
def test_기준점_둘_사슬_셈_거울():
g, k, c = compute_milestone(CHAIN, None)
# 거래처 → A = 2차 · A → B(기준점 → 기준점) = 2차 · B → 시점 = 1차 · 비포장은 사용자 값
assert (g["s2"], g["s1"], g["pv"], g["un"]) == (28.3, 0.6, 0, 0.3)
assert (g["paved"], g["unpaved"], g["total"]) == (28.9, 0.3, 29.2)
# 김천역 → B = 2차 · B → 시점 = 1차 · 목적지 중점 = 시점 → 중점 0.8 비포장
assert (k["s2"], k["s1"], k["un"], k["total"]) == (61.2, 0.6, 0.8, 62.6)
# 시점으로 바로 = 포장
assert (c["s2"], c["s1"], c["pv"], c["total"]) == (0, 0, 2.0, 2.0)
calc = [B08 + "Milestone_Calc.ts"]
assert _node(calc, f"computeMilestone({json.dumps(CHAIN)}, null)") == [g, k, c]
# 안 이은 기준점에서 사슬 멈춤(그 구간 · 뒤 구간 안 셈)
cut = {**CHAIN, "bases": [CHAIN["bases"][0], {**CHAIN["bases"][1], "linked": False}]}
g2, k2, _ = compute_milestone(cut, None)
assert (g2["s2"], g2["s1"], g2["un"], g2["total"]) == (28.3, 0, 0.3, 28.6)
assert (k2["s2"], k2["total"]) == (61.2, 62.0)
assert _node(calc, f"computeMilestone({json.dumps(cut)}, null)") == compute_milestone(cut, None)
# 선 자리 — 시점에 닿는 기준점 = base(1차) · 기준점 → 기준점 · 거래처 → 기준점 = via(2차)
kinds = _node(
calc, f"['g', 'A', 'B', 'c', 'start'].map((f) => segKind({json.dumps(CHAIN)}, f))"
)
assert kinds == ["via", "via", "base", "direct", "mid"]
def test_돌기_막음():
loop = {**CHAIN, "bases": [_base("A", "B", [_leg(None, 1)]), _base("B", "A", [_leg(None, 2)])]}
# 셈 = 방문 집합으로 끊음(A → B → A 멈춤 · 끝없이 안 돎)
assert [b["id"] for b in base_chain(loop, "A")] == ["A", "B"]
(g, *_) = compute_milestone(loop, None)
assert g["total"] == 26.8
calc = [B08 + "Milestone_Calc.ts"]
assert _node(calc, f"computeMilestone({json.dumps(loop)}, null)") == compute_milestone(
loop, None
)
self_loop = {**CHAIN, "bases": [_base("A", "A", [_leg(None, 1)])]}
assert [b["id"] for b in base_chain(self_loop, "A")] == ["A"]
# 연결 놓을 때 = 돌기면 취소(makesLoop) — A → B 사슬에서 B 를 A 에 이으면 돎
layout = [B08 + "Milestone_Layout.ts"]
d = json.dumps(CHAIN)
got = _node(
layout,
f"[makesLoop({d}, 'B', 'A'), makesLoop({d}, 'A', 'B'), makesLoop({d}, 'B', 'site'),"
f" makesLoop({d}, 'A', 'A'), chainOf({d}, 'A').map((b) => b.id),"
f" kmSegments({d}, 'g', [127.8, 35.6]).map((s) => s.text)]",
)
assert got[:4] == [True, False, False, True]
assert got[4] == ["A", "B"] and got[5] == ["g → A", "A → B", "B → 시점"]
def test_화면_기준점_여럿_등록_삭제():
modal = _read(B08 + "UI_Milestone_Modal.ts")
assert "fresh.bases.push({" in modal and 'to: "site",' in modal
assert "기준위치(있음" not in modal # 254 기준점 여럿 — 고르기 막음 없음
# 지운 기준점에 이은 상자 = 안 이음
assert "if (o.to === base.id)" in modal
state = _read(B08 + "UI_Milestone_State.ts")
assert "migrateMilestone(" in state and "bases: []," in state
page = _read(B08 + "UI_Milestone.ts")
assert "data.bases.length > 0" in page and 'to: "site",' in page
for name in ("Milestone_Calc.ts", "Milestone_Layout.ts", "UI_Milestone_Diagram.ts",
"UI_Milestone_Modal.ts", "UI_Milestone_State.ts", "UI_Milestone_LegModal.ts",
"UI_Milestone_Route.ts", "UI_Milestone.ts"): # fmt: skip
text = _read(B08 + name)
assert "data.base." not in text and ".via_base" not in text and ".base.legs" not in text
@@ -144,3 +144,17 @@ def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch):
assert client.get(base + "/file", params={"name": "place/zz.png"}).status_code == 404
for name in ("../milestone.json", "place/../x.png", "x/r1.png", "place/r1.json", "/etc/x.png"):
assert client.get(base + "/file", params={"name": name}).status_code == 422, name
def test_여러_기준점_상자_id(tmp_path, monkeypatch):
fake = _fake(monkeypatch)
bases = [
{"id": "b1", "name": "거창IC", "lon": 127.91, "lat": 35.68, "to": "b2"},
{"id": "b2", "name": "함양JC", "lon": 127.72, "lat": 35.52},
{"id": "b3", "name": "좌표 없음"},
]
_save(tmp_path, {"bases": bases, "routes": [{**DATA["routes"][0], "to": "b1"}]})
ms.refresh_images(tmp_path)
assert sorted(c[0] for c in fake.calls) == ["b1", "b2", "r1"]
got = _save(tmp_path, {"bases": bases[1:], "routes": []}) # b1 · r1 지움
assert got["place_images"] == {"b2": "place/b2.png"}