Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""시험 도움 — 예정노선 설계 내용(파일 꼴 · PLAN 590-1)을 메모리로 꽂음."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Network as network
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
|
||||
|
||||
def plan_of(lines: list, marks: list, pieces: list, epsg: int = 5186, points=()) -> dict:
|
||||
return {
|
||||
**store.empty_plan(epsg),
|
||||
"points": list(points),
|
||||
"lines": lines,
|
||||
"marks": marks,
|
||||
"pieces": pieces,
|
||||
}
|
||||
|
||||
|
||||
def use_plan(monkeypatch: Any, plan: dict, confirmed: dict | None = None) -> dict:
|
||||
"""`store.load_plan` · `store.year_status` 를 갈아 끼움 — `confirmed` = {연도: 확정 시각}
|
||||
(지문은 파일과 같게) · 돌려준 dict 의 plan · confirmed 를 고치면 다음 읽기에 반영."""
|
||||
state = {"plan": plan, "confirmed": dict(confirmed or {})}
|
||||
|
||||
async def load(_pid):
|
||||
return Path("."), state["plan"]
|
||||
|
||||
async def status(_pid):
|
||||
years = network.year_rows(store.plan_units(state["plan"]))
|
||||
return {
|
||||
y: {"fingerprint": years[y]["fingerprint"], "confirmed_at": at}
|
||||
for y, at in state["confirmed"].items()
|
||||
if y in years
|
||||
}
|
||||
|
||||
monkeypatch.setattr(store, "load_plan", load)
|
||||
monkeypatch.setattr(store, "year_status", status)
|
||||
return state
|
||||
|
||||
|
||||
def year_line_plan(parts: dict[int, list]) -> dict:
|
||||
"""본선 하나 = 연도 꺾은선 이음(앞 끝 = 뒤 시작) · 연도 경계 마크 · 조각 `p26` · `p27` …"""
|
||||
vertices: list = []
|
||||
pieces = []
|
||||
for year, points in sorted(parts.items()):
|
||||
start = network.draw.line_length(vertices)
|
||||
vertices += [list(p) for p in (points[1:] if vertices else points)]
|
||||
end = network.draw.line_length(vertices)
|
||||
pieces.append(
|
||||
{"id": f"p{year % 100}", "line": "main", "from_m": start, "to_m": end, "year": year}
|
||||
)
|
||||
marks = [{"line": "main", "at_m": p["to_m"]} for p in pieces[:-1]]
|
||||
return plan_of([{"id": "main", "vertices": vertices, "branch": None}], marks, pieces)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""D02 기준 지정(PLAN 529-3 · 560-2 · 579-1 · 585-1) — 경위도 ↔ TM 변환 · 지점 목록 하나 정본 API ·
|
||||
옛 시점 · 종점 꼴 이어 붙임 · 옛 한 점 꼴 감쌈 · 옛 연도 행 접기 · 저장된 좌표계 그대로.
|
||||
"""D02 기준 지정(PLAN 529-3 · 560-2 · 579-1 · 585-1 · 590-1) — 경위도 ↔ TM 변환 · 지점 목록 API ·
|
||||
지점 = 설계 내용 파일 `points` · 옛 DB 칸(시점 · 종점 꼴 · 한 점 꼴 · 연도 행 접기)은 열 때 옮김 ·
|
||||
저장된 좌표계 그대로.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_529_3_plan_points.py -q
|
||||
"""
|
||||
@@ -13,6 +14,7 @@ from fastapi.testclient import TestClient
|
||||
from common_util.common_util_auth import require_company
|
||||
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon, tm_to_lonlat
|
||||
from D02_PlanBase import D02_PlanBase_Router as router_mod
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
|
||||
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
|
||||
URL = f"/api/projects/{PID}/plan/points"
|
||||
@@ -57,21 +59,20 @@ A, B, C = (
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(monkeypatch):
|
||||
def api(monkeypatch, tmp_path):
|
||||
"""파일 = tmp_path 의 진짜 파일 · 없으면 옛 DB 칸(`base_json` · 연도 행)을 옮김."""
|
||||
state = {
|
||||
"project": {"project_kind": "PLAN", "project_year": 2026, "project_year_end": 2027},
|
||||
"rows": {},
|
||||
"base_json": None,
|
||||
"rows": [],
|
||||
"root": tmp_path,
|
||||
"session": {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1},
|
||||
}
|
||||
|
||||
async def load(project_id):
|
||||
return state["project"], {y: dict(r) for y, r in state["rows"].items()}
|
||||
plan = store.read_plan(tmp_path)
|
||||
return tmp_path, plan or store.migrate(state["base_json"], None, state["rows"])
|
||||
|
||||
async def store_base(project_id, base):
|
||||
state["project"] = {**state["project"], "plan_base_json": json.dumps(base)}
|
||||
|
||||
monkeypatch.setattr(router_mod, "_load", load)
|
||||
monkeypatch.setattr(router_mod, "_store_base", store_base)
|
||||
monkeypatch.setattr(store, "load_plan", load)
|
||||
app = FastAPI()
|
||||
app.include_router(router_mod.router)
|
||||
app.dependency_overrides[require_company] = lambda: state["session"]
|
||||
@@ -90,9 +91,9 @@ def test_put_lists(api) -> None:
|
||||
)
|
||||
end = data["points"][2]
|
||||
assert abs(end["lon"] - C["lon"]) < 1e-8 and abs(end["lat"] - C["lat"]) < 1e-8
|
||||
stored = json.loads(api.state["project"]["plan_base_json"])
|
||||
assert set(stored) == {"epsg", "points"} and set(stored["points"][1]) == {"x", "y"}
|
||||
assert api.state["rows"] == {} # 연도 행은 D02 가 안 만듦
|
||||
stored = json.loads(store.plan_path(api.state["root"]).read_text("utf-8"))
|
||||
assert stored["version"] == 1 and stored["epsg"] == 5186 and stored["lines"] == []
|
||||
assert set(stored["points"][1]) == {"x", "y"}
|
||||
|
||||
|
||||
def test_put_partial_and_empty(api) -> None:
|
||||
@@ -115,7 +116,7 @@ def test_get_joins_old_start_end_lists(api) -> None:
|
||||
a, b, c = (lonlat_to_tm(p["lon"], p["lat"], 5186) for p in (A, B, C))
|
||||
old = {"epsg": 5186, "starts": [{"x": a[0], "y": a[1]}, {"x": b[0], "y": b[1]}],
|
||||
"ends": [{"x": c[0], "y": c[1]}]} # fmt: skip
|
||||
api.state["project"]["plan_base_json"] = json.dumps(old)
|
||||
api.state["base_json"] = json.dumps(old)
|
||||
data = api.get(URL).json()
|
||||
assert [p["x"] for p in data["points"]] == pytest.approx([a[0], b[0], c[0]]) # 시점 뒤 종점
|
||||
|
||||
@@ -123,11 +124,11 @@ def test_get_joins_old_start_end_lists(api) -> None:
|
||||
def test_get_wraps_old_single_points(api) -> None:
|
||||
a, c = (lonlat_to_tm(p["lon"], p["lat"], 5186) for p in (A, C))
|
||||
old = {"epsg": 5186, "start": {"x": a[0], "y": a[1]}, "end": {"x": c[0], "y": c[1]}}
|
||||
api.state["project"]["plan_base_json"] = json.dumps(old)
|
||||
api.state["base_json"] = json.dumps(old)
|
||||
data = api.get(URL).json()
|
||||
assert [p["x"] for p in data["points"]] == pytest.approx([a[0], c[0]])
|
||||
old["end"] = None
|
||||
api.state["project"]["plan_base_json"] = json.dumps(old)
|
||||
api.state["base_json"] = json.dumps(old)
|
||||
assert [p["x"] for p in api.get(URL).json()["points"]] == pytest.approx([a[0]])
|
||||
|
||||
|
||||
@@ -142,11 +143,13 @@ def test_get_folds_old_year_rows(api) -> None:
|
||||
epsg = 5186
|
||||
a, b, c = (lonlat_to_tm(p["lon"], p["lat"], epsg) for p in (A, B, C))
|
||||
for year, s, e in ((2026, a, b), (2027, b, c)):
|
||||
api.state["rows"][year] = {
|
||||
"year": year,
|
||||
**{"start_x": s[0], "start_y": s[1], "end_x": e[0], "end_y": e[1]},
|
||||
"epsg": epsg,
|
||||
}
|
||||
api.state["rows"].append(
|
||||
{
|
||||
"year": year,
|
||||
**{"start_x": s[0], "start_y": s[1], "end_x": e[0], "end_y": e[1]},
|
||||
"epsg": epsg,
|
||||
}
|
||||
)
|
||||
data = api.get(URL).json()
|
||||
assert data["epsg"] == epsg
|
||||
assert [p["x"] for p in data["points"]] == pytest.approx([a[0], c[0]])
|
||||
@@ -154,3 +157,14 @@ def test_get_folds_old_year_rows(api) -> None:
|
||||
|
||||
def test_get_empty_new_project(api) -> None:
|
||||
assert api.get(URL).json() == {"status": "success", "epsg": None, "points": []}
|
||||
|
||||
|
||||
def test_put_keeps_line_epsg(api) -> None:
|
||||
"""선이 있으면 지점을 다 지워도 좌표계 그대로(파일 머리 한 곳 · 선 좌표가 그 좌표계)."""
|
||||
api.put(URL, json={"points": [A]})
|
||||
path = store.plan_path(api.state["root"])
|
||||
plan = json.loads(path.read_text("utf-8"))
|
||||
plan["lines"] = [{"id": "main", "vertices": [[0.0, 0.0], [1.0, 0.0]], "branch": None}]
|
||||
path.write_text(json.dumps(plan), "utf-8")
|
||||
assert api.put(URL, json={}).json()["points"] == []
|
||||
assert json.loads(path.read_text("utf-8"))["epsg"] == 5186
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""D 예정노선 2단계 노선 정본(PLAN 562-2) — 손 그은 선 · 갈래 · 마크 · 조각 → 서버가 다시 셈 ·
|
||||
연도 행 · 옛 연도 노선을 선으로 · GET · PUT(산림청만 · 틀린 꼴 거절 · 업체 지정된 조각 지움 막음).
|
||||
"""D 예정노선 2단계 노선 정본(PLAN 562-2 · 590-1) — 손 그은 선 · 갈래 · 마크 · 조각 →
|
||||
서버가 다시 셈 · 연도 묶음 · 옛 연도 노선을 선으로 · GET · PUT(산림청만 · 틀린 꼴 거절 ·
|
||||
업체 지정된 조각 지움 막음) ·
|
||||
[저장] = 설계 내용 파일 + 연도 상태 줄만(옛 DB 설계 칸 안 씀) · 파일 쓰기 실패 = DB 되돌림.
|
||||
|
||||
옛 추천 엔진 시험(합성 지형 갈래 넷 · 저장본 · 나란히 셈)은 엔진과 함께 걷음.
|
||||
|
||||
@@ -14,6 +16,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import require_company
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Network as net
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
|
||||
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
|
||||
MAIN = [[0.0, 0.0], [30.0, 40.0], [30.0, 100.0], [90.0, 180.0]] # 50 + 60 + 100 = 210 m
|
||||
@@ -174,22 +177,27 @@ class _Connection:
|
||||
class _Db:
|
||||
def __init__(self):
|
||||
self.log: list = []
|
||||
self.answers: dict = {"FROM projects": [{"project_year": 2026, "plan_base_json": "{}"}]}
|
||||
self.answers: dict = {"FROM projects": [(2026,)]}
|
||||
|
||||
def acquire(self):
|
||||
return _Connection(self)
|
||||
|
||||
|
||||
BASE = {"epsg": 5186, "points": [{"x": 0.0, "y": 0.0}, {"x": 90.0, "y": 180.0}]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
def client(monkeypatch, tmp_path):
|
||||
"""파일 = tmp_path 진짜 파일 · 없으면 옛 DB 칸(`old`)을 옮김 · DB = 가짜 기록."""
|
||||
db = _Db()
|
||||
monkeypatch.setattr(net, "get_db_pool", lambda: db)
|
||||
base = {"epsg": 5186, "points": [{"x": 0.0, "y": 0.0}, {"x": 90.0, "y": 180.0}]}
|
||||
old = {"base": json.dumps(BASE), "network": None, "rows": []}
|
||||
|
||||
async def load_base(project_id):
|
||||
return base
|
||||
async def load(project_id):
|
||||
plan = store.read_plan(tmp_path)
|
||||
return tmp_path, plan or store.migrate(old["base"], old["network"], old["rows"])
|
||||
|
||||
monkeypatch.setattr(net.plan_base, "load_base", load_base)
|
||||
monkeypatch.setattr(store, "load_plan", load)
|
||||
app = FastAPI()
|
||||
app.include_router(net.router)
|
||||
session = {"role": "USER", "email": "a@forest.go.kr"}
|
||||
@@ -197,62 +205,66 @@ def client(monkeypatch):
|
||||
test = TestClient(app)
|
||||
test.db = db
|
||||
test.session = session
|
||||
test.base = base
|
||||
test.old = old
|
||||
test.file = store.plan_path(tmp_path)
|
||||
return test
|
||||
|
||||
|
||||
URL = f"/api/projects/{PID}/plan/network"
|
||||
|
||||
|
||||
def test_get_saved_legacy_or_empty(client) -> None:
|
||||
def test_get_migrates_old_db_or_empty(client) -> None:
|
||||
assert client.get(URL).json()["network"] is None
|
||||
client.db.answers["FROM plan_route_years"] = [
|
||||
client.old["rows"] = [
|
||||
{"year": 2026, "epsg": 5186, "route_json": '{"planned": [[0, 0], [30, 40]]}'}
|
||||
]
|
||||
got = client.get(URL).json()
|
||||
assert got["legacy"] is True and got["derived"]["pieces"][0]["length_m"] == 50
|
||||
client.db.answers["FROM plan_networks"] = [{"network_json": json.dumps(_network())}]
|
||||
assert got["legacy"] is False and got["derived"]["pieces"][0]["length_m"] == 50
|
||||
client.old["network"] = json.dumps(_network())
|
||||
got = client.get(URL).json()
|
||||
assert got["legacy"] is False and len(got["derived"]["pieces"]) == 3
|
||||
assert got["base"]["points"][1] == {"x": 90.0, "y": 180.0} and got["year_start"] == 2026
|
||||
assert len(got["derived"]["pieces"]) == 3 and got["network"]["epsg"] == 5186
|
||||
assert got["base"]["points"][1]["x"] == 90.0 and got["year_start"] == 2026
|
||||
assert not client.file.exists() # 열기만 = 쓰지 않음
|
||||
|
||||
|
||||
def test_put_stores_network_pieces_years(client) -> None:
|
||||
def test_put_writes_file_and_year_status_only(client) -> None:
|
||||
got = client.put(URL, json=_network())
|
||||
assert got.status_code == 200 and got.json()["derived"]["pieces"][1]["from_m"] == 75.0
|
||||
assert got.json()["year_start"] == 2026
|
||||
saved = json.loads(client.file.read_text("utf-8"))
|
||||
assert saved["version"] == 1 and saved["epsg"] == 5186
|
||||
assert saved["points"] == BASE["points"] # 옛 지점 칸 → 파일로 옮김
|
||||
assert [p["id"] for p in saved["pieces"]] == ["p1", "p2", "p3"]
|
||||
assert "length_m" not in json.dumps(saved) # 파생 저장 안 함
|
||||
log = client.db.log
|
||||
assert log[-1][0] == "COMMIT"
|
||||
pieces = [a for s, a in log if s.startswith("INSERT INTO plan_route_pieces")]
|
||||
assert [(a[1], a[2], a[3]) for a in pieces] == [
|
||||
("p1", "main", 2026),
|
||||
("p2", "main", 2027),
|
||||
("p3", "b1", 2026),
|
||||
]
|
||||
sqls = [s for s, _ in log]
|
||||
for column in ("plan_route_pieces", "plan_networks", "plan_base_json", "route_json"):
|
||||
assert not [s for s in sqls if column in s and not s.startswith("SELECT")], column
|
||||
years = [a for s, a in log if s.startswith("INSERT INTO plan_route_years")]
|
||||
assert [a[1] for a in years] == [2026, 2027]
|
||||
assert json.loads(years[0][7])["pieces"][1]["id"] == "p3"
|
||||
assert [a[1] for a in years] == [2026, 2027] and all(len(a[2]) == 40 for a in years)
|
||||
assert ("UPDATE projects SET project_year_end = %s WHERE id = %s", (2027, PID)) in log
|
||||
stale = [a for s, a in log if "year NOT IN" in s]
|
||||
assert stale == [(PID, 2026, 2027)]
|
||||
assert not [s for s, _ in log if "plan_base_json = %s" in s] # 정본 칸 있음 = 안 굳힘
|
||||
assert stale == [(PID, 2026, 2027)] # 없는 연도 줄 지움(빈 줄 남기지 않음)
|
||||
|
||||
|
||||
def test_put_freezes_folded_base_first(client) -> None:
|
||||
client.db.answers["FROM projects"] = [{"project_year": 2026, "plan_base_json": None}]
|
||||
assert client.put(URL, json=_network()).status_code == 200
|
||||
sqls = [(s, a) for s, a in client.db.log if s.startswith("UPDATE projects SET plan_base_json")]
|
||||
assert len(sqls) == 1 and json.loads(sqls[0][1][0]) == client.base
|
||||
first_year = next(i for i, (s, _) in enumerate(client.db.log) if "plan_route_years" in s)
|
||||
assert client.db.log.index(sqls[0]) < first_year
|
||||
|
||||
|
||||
def test_put_reset_clears_year_routes(client) -> None:
|
||||
def test_put_reset_drops_year_rows(client) -> None:
|
||||
got = client.put(URL, json={"epsg": 5186, "lines": [], "marks": [], "pieces": []})
|
||||
assert got.status_code == 200 and got.json()["derived"] == {"lines": [], "pieces": []}
|
||||
sqls = [s for s, _ in client.db.log]
|
||||
assert any(s.startswith("UPDATE plan_route_years SET route_json = NULL") for s in sqls)
|
||||
assert "DELETE FROM plan_route_years WHERE project_id = %s" in sqls
|
||||
assert not any("project_year_end" in s for s in sqls)
|
||||
assert json.loads(client.file.read_text("utf-8"))["lines"] == []
|
||||
|
||||
|
||||
def test_put_file_failure_rolls_back(client, monkeypatch) -> None:
|
||||
def broken(root, plan):
|
||||
raise OSError("디스크")
|
||||
|
||||
monkeypatch.setattr(store, "save_plan", broken)
|
||||
with pytest.raises(OSError):
|
||||
client.put(URL, json=_network())
|
||||
assert ("ROLLBACK", None) in client.db.log and ("COMMIT", None) not in client.db.log
|
||||
|
||||
|
||||
def test_put_rejects(client) -> None:
|
||||
@@ -261,7 +273,7 @@ def test_put_rejects(client) -> None:
|
||||
assert (
|
||||
client.put(URL, json=_network(lines=[{"id": "a b", "vertices": MAIN}])).status_code == 422
|
||||
)
|
||||
# 업체가 지정된 조각 · 옛 연도를 지우면 막음
|
||||
# 업체가 지정된 조각을 지우면 막음 · 파일 안 씀
|
||||
client.db.answers["piece_id IS NOT NULL"] = [("p2",)]
|
||||
one = _network(
|
||||
lines=[{"id": "main", "vertices": MAIN}],
|
||||
@@ -269,10 +281,9 @@ def test_put_rejects(client) -> None:
|
||||
pieces=[{"id": "p1", "line": "main", "from_m": 0, "to_m": 210, "year": 2026}],
|
||||
)
|
||||
assert client.put(URL, json=one).status_code == 409
|
||||
client.db.answers["piece_id IS NOT NULL"] = []
|
||||
client.db.answers["piece_id IS NULL"] = [(2027,)]
|
||||
assert client.put(URL, json=one).status_code == 409
|
||||
assert not client.file.exists() and ("ROLLBACK", None) in client.db.log
|
||||
assert not [s for s, _ in client.db.log if s.startswith("INSERT")]
|
||||
assert client.put(URL, json=_network(epsg=5187)).status_code == 400 # 기준 지정과 다른 좌표계
|
||||
client.db.answers["piece_id IS NOT NULL"] = []
|
||||
assert client.put(URL, json=_network(epsg=5187)).status_code == 400 # 파일 좌표계와 다름
|
||||
client.session["email"] = "a@gmail.com"
|
||||
assert client.put(URL, json=_network()).status_code == 403
|
||||
|
||||
@@ -26,6 +26,8 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from helper_plan_file import use_plan, year_line_plan # noqa: E402
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata # noqa: E402
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import OutputsWouldBeDiscarded # noqa: E402
|
||||
from common_util.common_util_route_geometry import find_planned_route_file # noqa: E402
|
||||
@@ -61,12 +63,12 @@ def _assignee(**kw):
|
||||
"id": 7,
|
||||
"project_id": PLAN_ID,
|
||||
"year": 2026,
|
||||
"piece_id": "p26",
|
||||
"company_id": 5,
|
||||
"linked_project_id": None,
|
||||
"name": "시험 예정노선",
|
||||
"region": "일월.용화",
|
||||
"road_type": "work",
|
||||
"design_speed_kph": 20,
|
||||
"route_start_m": 0.0,
|
||||
"epsg": 5186,
|
||||
"route_json": json.dumps(_route(P2026)),
|
||||
@@ -158,8 +160,8 @@ def env(monkeypatch, tmp_path):
|
||||
raise OutputsWouldBeDiscarded(["노선 · 종단 설계"])
|
||||
return 99
|
||||
|
||||
async def record(project_id, assignee, fingerprint, speed):
|
||||
state["links"].append((project_id, assignee["id"], fingerprint, speed))
|
||||
async def record(project_id, assignee, fingerprint):
|
||||
state["links"].append((project_id, assignee["id"], fingerprint))
|
||||
|
||||
monkeypatch.setattr(link, "is_analysis_running", false)
|
||||
monkeypatch.setattr(link, "get_project_storage_relative_path", storage)
|
||||
@@ -211,7 +213,7 @@ def test_link_writes_route_set_like_upload(env, monkeypatch):
|
||||
stage_meta = json.loads((env["root"] / "B03_FileInput/metadata.json").read_text("utf-8"))
|
||||
assert len(stage_meta["files"]) == 5
|
||||
fp = link.route_fingerprint(_assignee()["route_json"])
|
||||
assert env["links"] == [(ROAD_ID, 7, fp, 20)] and len(fp) == 40
|
||||
assert env["links"] == [(ROAD_ID, 7, fp)] and len(fp) == 40
|
||||
assert any("SUPERSEDED" in sql for sql, _ in env["log"] if isinstance(sql, str))
|
||||
|
||||
|
||||
@@ -278,50 +280,6 @@ def test_new_mode_name_year_once(env, monkeypatch):
|
||||
assert made["name"] == "2026 홍천 노선 계획"
|
||||
|
||||
|
||||
def _stage2_writes(log):
|
||||
return [
|
||||
args
|
||||
for sql, args in log
|
||||
if isinstance(sql, str) and "UPDATE project_workflow_stages" in sql
|
||||
]
|
||||
|
||||
|
||||
def test_new_mode_seeds_b05_design_speed(env, monkeypatch):
|
||||
"""531-4 — 새 임도 = 예정노선 설계속도가 단계 2 params options 에 · B05 가 그 값을 씀."""
|
||||
from B05_Profile.B05_Profile_PageSettings import grade_options_of
|
||||
|
||||
_use(monkeypatch, _assignee(design_speed_kph=30, road_type="main"))
|
||||
|
||||
async def create_project(**kw):
|
||||
return {"project_id": ROAD_ID}
|
||||
|
||||
monkeypatch.setattr(link, "create_project", create_project)
|
||||
_link(mode="new", project_id=None)
|
||||
(args,) = _stage2_writes(env["log"])
|
||||
params = json.loads(args[0])
|
||||
assert args[1:] == (ROAD_ID, 2) and params == {"options": {"design_speed_kph": 30}}
|
||||
# 자동설계 체인이 grade_class trunk(기본)로 params 를 다시 써도 이 값을 이어 넘김
|
||||
chained = {"options": {"grade_class": "trunk", "design_speed_kph": 30}}
|
||||
assert grade_options_of("main", chained).design_speed_kph == 30
|
||||
chain = (ROOT / "B03_FileInput/B03_FileInput_Service_Chain.py").read_text("utf-8")
|
||||
assert "'$.options.design_speed_kph'" in chain and "design_speed_kph=int(seeded_speed)" in chain
|
||||
|
||||
|
||||
def test_existing_and_odd_speed_leave_stage_params(env, monkeypatch):
|
||||
"""기존 프로젝트는 사용자 값 보존 · 20/30/40 밖 값은 안 넣음."""
|
||||
_use(monkeypatch, _assignee(design_speed_kph=30), ROAD)
|
||||
_link()
|
||||
assert _stage2_writes(env["log"]) == []
|
||||
|
||||
async def create_project(**kw):
|
||||
return {"project_id": ROAD_ID}
|
||||
|
||||
monkeypatch.setattr(link, "create_project", create_project)
|
||||
_use(monkeypatch, _assignee(design_speed_kph=25))
|
||||
_link(mode="new", project_id=None)
|
||||
assert _stage2_writes(env["log"]) == []
|
||||
|
||||
|
||||
def test_fingerprint_update_and_revert():
|
||||
linked = link.route_fingerprint(_route(P2026))
|
||||
moved = [p[:] for p in P2026]
|
||||
@@ -338,72 +296,59 @@ def test_fingerprint_update_and_revert():
|
||||
assert link.update_available(json.dumps(_route(moved)), linked, False) is False
|
||||
|
||||
|
||||
def test_assigned_list_flags_update(monkeypatch):
|
||||
linked = link.route_fingerprint(_route(P2026))
|
||||
moved = [[x + 1.0, y] for x, y in P2026]
|
||||
@pytest.fixture
|
||||
def plan_file(monkeypatch):
|
||||
"""설계 내용 파일 — 본선 p26(2026 · 확정) · p27(2027 · 확정 전)."""
|
||||
return use_plan(monkeypatch, year_line_plan({2026: P2026, 2027: P2027}), {2026: "x"})
|
||||
|
||||
|
||||
def test_assigned_list_flags_update(plan_file, monkeypatch):
|
||||
moved = link.route_fingerprint(_route([[x + 1.0, y] for x, y in P2026]))
|
||||
base = {
|
||||
"project_id": PLAN_ID,
|
||||
"project_name": "시험",
|
||||
"linked_project_id": ROAD_ID,
|
||||
"linked_project_name": "임도",
|
||||
"source_fingerprint": moved,
|
||||
}
|
||||
unlinked = {"linked_project_id": None, "linked_project_name": None, "source_fingerprint": None}
|
||||
rows = [
|
||||
{
|
||||
"assignee_id": 7,
|
||||
"project_id": PLAN_ID,
|
||||
"project_name": "시험",
|
||||
"year": 2026,
|
||||
"linked_project_id": ROAD_ID,
|
||||
"linked_project_name": "임도",
|
||||
"source_fingerprint": linked,
|
||||
"route_json": json.dumps(_route(moved)),
|
||||
"confirmed_at": "2026-10-25 12:00:00",
|
||||
},
|
||||
{
|
||||
"assignee_id": 9,
|
||||
"project_id": PLAN_ID,
|
||||
"project_name": "시험",
|
||||
"year": 2028,
|
||||
"linked_project_id": ROAD_ID,
|
||||
"linked_project_name": "임도",
|
||||
"source_fingerprint": linked,
|
||||
"route_json": json.dumps(_route(moved)),
|
||||
"confirmed_at": None,
|
||||
},
|
||||
{
|
||||
"assignee_id": 8,
|
||||
"project_id": PLAN_ID,
|
||||
"project_name": "시험",
|
||||
"year": 2027,
|
||||
"linked_project_id": None,
|
||||
"linked_project_name": None,
|
||||
"source_fingerprint": None,
|
||||
"route_json": json.dumps(_route(P2027)),
|
||||
"confirmed_at": "2026-10-25 12:00:00",
|
||||
},
|
||||
{**base, "assignee_id": 7, "piece_id": "p26"}, # 연결 뒤 노선 바뀜 → 업데이트
|
||||
{**base, "assignee_id": 9, "piece_id": "p27"}, # 확정 전 → 알림 없음
|
||||
{**base, "assignee_id": 8, "piece_id": "p26", **unlinked},
|
||||
{**base, "assignee_id": 10, "piece_id": "p27", **unlinked}, # 확정 전 · 연결 없음 → 뺌
|
||||
]
|
||||
log = []
|
||||
monkeypatch.setattr(d01, "get_db_pool", lambda: FakeConn(log, rows))
|
||||
monkeypatch.setattr(d01, "get_db_pool", lambda: FakeConn([], rows))
|
||||
out = asyncio.run(d01.assigned_plan_routes(SESSION))["routes"]
|
||||
assert [r["update_available"] for r in out] == [True, False, False]
|
||||
assert "route_json" not in out[0] and out[0]["assignee_id"] == 7
|
||||
assert "confirmed_at IS NOT NULL" in log[0][0]
|
||||
assert [(r["assignee_id"], r["year"], r["update_available"]) for r in out] == [
|
||||
(7, 2026, True),
|
||||
(8, 2026, False),
|
||||
(9, 2027, False),
|
||||
]
|
||||
assert "route_json" not in out[0] and "source_fingerprint" not in out[0]
|
||||
|
||||
|
||||
def test_plan_source(monkeypatch):
|
||||
linked = link.route_fingerprint(_route(P2026))
|
||||
def test_plan_source(plan_file, monkeypatch):
|
||||
row = {
|
||||
"source_fingerprint": linked,
|
||||
"source_fingerprint": link.route_fingerprint(_route(P2026)),
|
||||
"source_id": PLAN_ID,
|
||||
"plan_name": "시험",
|
||||
"assignee_id": 7,
|
||||
"year": 2026,
|
||||
"route_json": json.dumps(_route(P2026)),
|
||||
"confirmed_at": "2026-10-25 12:00:00",
|
||||
"piece_id": "p26",
|
||||
}
|
||||
sqls = []
|
||||
|
||||
async def one(sql, args):
|
||||
sqls.append(sql)
|
||||
return row
|
||||
|
||||
monkeypatch.setattr(link, "_fetch_one", one)
|
||||
out = asyncio.run(link.plan_source(ROAD_ID))
|
||||
assert out["linked"] and out["update_available"] is False and out["year"] == 2026
|
||||
row["route_json"] = json.dumps(_route([[x, y + 2.0] for x, y in P2026]))
|
||||
assert "s.deleted_at IS NULL" in sqls[0] # 지운 예정노선 띠 안 남김
|
||||
row["source_fingerprint"] = link.route_fingerprint(_route([[x, y + 2.0] for x, y in P2026]))
|
||||
assert asyncio.run(link.plan_source(ROAD_ID))["update_available"] is True
|
||||
row["confirmed_at"] = None # 확정 풀림 → 알림 없음
|
||||
plan_file["confirmed"].clear() # 확정 풀림 → 알림 없음
|
||||
assert asyncio.run(link.plan_source(ROAD_ID))["update_available"] is False
|
||||
|
||||
async def none(sql, args):
|
||||
@@ -414,7 +359,7 @@ def test_plan_source(monkeypatch):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan_repo(monkeypatch):
|
||||
def plan_repo(plan_file, monkeypatch):
|
||||
async def project(_pid):
|
||||
return {
|
||||
"id": PLAN_ID,
|
||||
@@ -424,27 +369,28 @@ def plan_repo(monkeypatch):
|
||||
"route_start_m": 0.0,
|
||||
}
|
||||
|
||||
async def years(_pid):
|
||||
async def assignees(_pid): # DB 업체 줄 — 연도 칸 없음(파일 조각에서)
|
||||
return [
|
||||
{"year": 2026, "epsg": 5186, "route_json": _route(P2026), "confirmed_at": "x"},
|
||||
{"year": 2027, "epsg": 5186, "route_json": _route(P2027), "confirmed_at": None},
|
||||
{"id": 7, "piece_id": "p26", "company_id": 5, "company_name": "가", "email": "a@ga.kr"},
|
||||
{"id": 8, "piece_id": "p27", "company_id": 5, "company_name": "가", "email": "a@ga.kr"},
|
||||
{
|
||||
"id": 9,
|
||||
"piece_id": "p27",
|
||||
"company_id": None,
|
||||
"company_name": "나",
|
||||
"email": "b@na.kr",
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"piece_id": "없음",
|
||||
"company_id": 6,
|
||||
"company_name": "다",
|
||||
"email": "c@da.kr",
|
||||
},
|
||||
]
|
||||
|
||||
async def assignees(_pid):
|
||||
return [
|
||||
{"id": 7, "year": 2026, "company_id": 5, "company_name": "가", "email": "a@ga.kr"},
|
||||
{"id": 8, "year": 2027, "company_id": 5, "company_name": "가", "email": "a@ga.kr"},
|
||||
{"id": 9, "year": 2027, "company_id": None, "company_name": "나", "email": "b@na.kr"},
|
||||
{"id": 10, "year": 2028, "company_id": 6, "company_name": "다", "email": "c@da.kr"},
|
||||
]
|
||||
|
||||
async def no_pieces(_pid): # 옛 프로젝트 — 조각 없음
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(repo, "get_plan_project", project)
|
||||
monkeypatch.setattr(repo, "list_year_routes", years)
|
||||
monkeypatch.setattr(repo, "list_assignees", assignees)
|
||||
monkeypatch.setattr(repo, "list_pieces", no_pieces)
|
||||
|
||||
|
||||
def _zip_names(response):
|
||||
@@ -454,13 +400,13 @@ def _zip_names(response):
|
||||
def test_export_forest_gets_all(plan_repo):
|
||||
forest = {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1}
|
||||
names = _zip_names(asyncio.run(d04.export_plan(PLAN_ID, forest)))
|
||||
assert {n.split("/")[0] for n in names} == {"2026", "2027", "전체"}
|
||||
assert {n.split("/")[0] for n in names} == {"2026_본선_p26", "2027_본선_p27", "전체"}
|
||||
assert sum(n.endswith(".shp") for n in names) == 3
|
||||
|
||||
|
||||
def test_export_shared_gets_own_confirmed_year(plan_repo):
|
||||
names = _zip_names(asyncio.run(d04.export_plan(PLAN_ID, SESSION)))
|
||||
assert {n.split("/")[0] for n in names} == {"2026"} # 2027 = 확정 전 · 전체 없음
|
||||
assert {n.split("/")[0] for n in names} == {"2026_본선_p26"} # 2027 = 확정 전 · 전체 없음
|
||||
with pytest.raises(HTTPException) as err:
|
||||
asyncio.run(d04.export_plan(PLAN_ID, {**SESSION, "company_id": 9}))
|
||||
assert err.value.status_code == 403
|
||||
@@ -475,11 +421,12 @@ def test_summary_year_rows(plan_repo):
|
||||
first["length_m"]
|
||||
)
|
||||
assert out["years"][1]["confirmed_at"] is None and len(out["assignees"]) == 4
|
||||
assert [a["year"] for a in out["assignees"]] == [2026, 2027, 2027, None]
|
||||
|
||||
|
||||
def test_shared_company_sees_only_own_firm(plan_repo):
|
||||
"""지정 회사 = 자기 연도 줄 · 자기 행만 — 남의 업체 이름 · 메일은 응답에 없음(요구 351)."""
|
||||
other = {"role": "USER", "email": "x@ga.kr", "company_id": 6} # 2028 만(노선 없음)
|
||||
other = {"role": "USER", "email": "x@ga.kr", "company_id": 6} # 없는 조각만
|
||||
for session, years, ids in ((SESSION, [2026, 2027], [7, 8]), (other, [], [10])):
|
||||
out = asyncio.run(d04.get_plan_summary(PLAN_ID, session))
|
||||
assert [y["year"] for y in out["years"]] == years
|
||||
|
||||
@@ -26,6 +26,8 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from helper_plan_file import use_plan, year_line_plan # noqa: E402
|
||||
|
||||
import common_util.common_util_email as email_mod # noqa: E402
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata # noqa: E402
|
||||
from D04_PlanConfirm import D04_PlanConfirm_Repository as repo # noqa: E402
|
||||
@@ -244,28 +246,14 @@ FOREST = {"user_id": 1, "email": "a@korea.go.kr", "role": "MEMBER", "company_id"
|
||||
OUTSIDER = {"user_id": 2, "email": "b@company.co.kr", "role": "MASTER", "company_id": 8}
|
||||
|
||||
|
||||
def _route_row(year: int, points, start: float, end: float):
|
||||
return {
|
||||
"year": year,
|
||||
"epsg": 5186,
|
||||
"route_json": {
|
||||
"planned": [list(p) + [0.0] for p in points],
|
||||
"start_chainage_m": start,
|
||||
"end_chainage_m": end,
|
||||
},
|
||||
"fingerprint": "f" * 40,
|
||||
"confirmed_at": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_db(monkeypatch):
|
||||
state = {
|
||||
"years": [_route_row(2026, Y2026, 0.0, 330.0), _route_row(2027, Y2027, 330.0, 520.0)],
|
||||
"file": use_plan(monkeypatch, year_line_plan({2026: Y2026, 2027: Y2027})),
|
||||
"assignees": [
|
||||
{
|
||||
"id": 1,
|
||||
"year": 2026,
|
||||
"piece_id": "p26",
|
||||
"company_id": None,
|
||||
"company_name": "가나다건설",
|
||||
"email": "ga@x.kr",
|
||||
@@ -274,7 +262,7 @@ def fake_db(monkeypatch):
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"year": 2026,
|
||||
"piece_id": "p26",
|
||||
"company_id": 9,
|
||||
"company_name": "라마",
|
||||
"email": "ra@x.kr",
|
||||
@@ -283,7 +271,7 @@ def fake_db(monkeypatch):
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"year": 2027,
|
||||
"piece_id": "p27",
|
||||
"company_id": None,
|
||||
"company_name": "메일없음",
|
||||
"email": None,
|
||||
@@ -306,9 +294,6 @@ def fake_db(monkeypatch):
|
||||
"route_start_m": 0.0,
|
||||
}
|
||||
|
||||
async def years(_pid):
|
||||
return state["years"]
|
||||
|
||||
async def assignees(_pid):
|
||||
return state["assignees"]
|
||||
|
||||
@@ -328,13 +313,8 @@ def fake_db(monkeypatch):
|
||||
state["mails"].append((to_email, subject, attachments))
|
||||
return True
|
||||
|
||||
async def no_pieces(_pid): # 옛 프로젝트 — 조각 없음
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(repo, "get_plan_project", project)
|
||||
monkeypatch.setattr(repo, "list_year_routes", years)
|
||||
monkeypatch.setattr(repo, "list_assignees", assignees)
|
||||
monkeypatch.setattr(repo, "list_pieces", no_pieces)
|
||||
monkeypatch.setattr(repo, "mark_confirmed", confirmed)
|
||||
monkeypatch.setattr(repo, "mark_notified", notified)
|
||||
monkeypatch.setattr(repo, "apply_assignee_changes", apply)
|
||||
@@ -367,10 +347,12 @@ def test_finalize_sends_zip_per_assignee(fake_db):
|
||||
assert fake_db["notified"] == [1]
|
||||
((to, subject, attachments),) = fake_db["mails"]
|
||||
assert to == "ga@x.kr" and "2026" in subject
|
||||
assert set(fake_db["confirmed"]) == {2026, 2027} # 연도 → 파일 지문
|
||||
name, blob, mime = attachments[0]
|
||||
assert name == "시험_예정노선_2026.zip" and mime == "application/zip" and len(blob) > 1000
|
||||
assert name == "시험_예정노선_2026_본선_p26.zip" and mime == "application/zip"
|
||||
assert len(blob) > 1000
|
||||
with zipfile.ZipFile(io.BytesIO(blob)) as archive:
|
||||
assert any(n.endswith("_2026.shp") for n in archive.namelist())
|
||||
assert any(n.endswith("_2026_본선_p26.shp") for n in archive.namelist())
|
||||
assert not any("_2027" in n for n in archive.namelist()) # 다른 연도는 안 샘
|
||||
|
||||
# 다시 보내기
|
||||
@@ -380,9 +362,8 @@ def test_finalize_sends_zip_per_assignee(fake_db):
|
||||
|
||||
|
||||
def test_finalize_unsaved_year_409(fake_db):
|
||||
fake_db["years"][1]["route_json"] = None
|
||||
with pytest.raises(HTTPException) as err:
|
||||
asyncio.run(router.finalize_plan(uuid4(), router.FinalizeRequest(years=[2027]), FOREST))
|
||||
with pytest.raises(HTTPException) as err: # 파일에 없는 연도
|
||||
asyncio.run(router.finalize_plan(uuid4(), router.FinalizeRequest(years=[2030]), FOREST))
|
||||
assert err.value.status_code == 409
|
||||
assert fake_db["confirmed"] == []
|
||||
|
||||
@@ -394,10 +375,10 @@ def test_put_assignees_validation_and_diff(fake_db):
|
||||
)
|
||||
|
||||
for bad in (
|
||||
router.AssigneeIn(year=2026, company_name="수기", email="메일아님"),
|
||||
router.AssigneeIn(year=2026, email="x@y.kr"),
|
||||
router.AssigneeIn(year=2030, company_name="없는해", email="x@y.kr"),
|
||||
router.AssigneeIn(year=2026, company_id=99),
|
||||
router.AssigneeIn(piece_id="p26", company_name="수기", email="메일아님"),
|
||||
router.AssigneeIn(piece_id="p26", email="x@y.kr"),
|
||||
router.AssigneeIn(piece_id="없음", company_name="없는조각", email="x@y.kr"),
|
||||
router.AssigneeIn(piece_id="p26", company_id=99),
|
||||
):
|
||||
with pytest.raises(HTTPException) as err:
|
||||
put([bad])
|
||||
@@ -407,9 +388,9 @@ def test_put_assignees_validation_and_diff(fake_db):
|
||||
# 2026: 가나다 유지(메일 바꿈) · 라마(등록사 9) 유지 · 새 수기 추가 / 2027 은 안 보냈으니 그대로
|
||||
put(
|
||||
[
|
||||
router.AssigneeIn(year=2026, company_name="가나다건설", email="GA@x.kr"),
|
||||
router.AssigneeIn(year=2026, company_id=9),
|
||||
router.AssigneeIn(year=2026, company_name="새업체", email="new@x.kr"),
|
||||
router.AssigneeIn(piece_id="p26", company_name="가나다건설", email="GA@x.kr"),
|
||||
router.AssigneeIn(piece_id="p26", company_id=9),
|
||||
router.AssigneeIn(piece_id="p26", company_name="새업체", email="new@x.kr"),
|
||||
]
|
||||
)
|
||||
deleted, updated, inserted = fake_db["applied"]
|
||||
@@ -418,5 +399,5 @@ def test_put_assignees_validation_and_diff(fake_db):
|
||||
assert [i["company_name"] for i in inserted] == ["새업체"]
|
||||
assert next(u for u in updated if u["id"] == 2)["email"] == "master@reg.kr"
|
||||
|
||||
put([router.AssigneeIn(year=2026, company_name="새업체", email="new@x.kr")])
|
||||
put([router.AssigneeIn(piece_id="p26", company_name="새업체", email="new@x.kr")])
|
||||
assert sorted(fake_db["applied"][0]) == [1, 2] # 빠진 업체 지움
|
||||
|
||||
@@ -93,7 +93,7 @@ class _Cursor:
|
||||
return {"n": 2, "name": "회사", "member_count": 3, "companies": 4, "users": 9}
|
||||
|
||||
async def fetchall(self):
|
||||
if "temp_upload_batches" in self.sql or "plan_route_years" in self.sql:
|
||||
if "temp_upload_batches" in self.sql or "plan_route_assignees" in self.sql:
|
||||
return []
|
||||
return [{"id": 1, "name": "회사"}]
|
||||
|
||||
@@ -150,3 +150,40 @@ def test_home_blocks_by_role(monkeypatch, session, keys, join):
|
||||
assert ("join_requests" in body["todos"]) is join
|
||||
if "system" in body:
|
||||
assert body["system"]["server"] == {"cpu": 1, "memory": 2, "disk": None}
|
||||
|
||||
|
||||
def test_forest_counts_from_plan_file(monkeypatch):
|
||||
"""산림청 홈(PLAN 590-1) — 분석 전 = 노선 안 그은 프로젝트 수 · 분석됨 · 확정 = 파일 연도 ·
|
||||
지정 대기 = 확정 연도 중 그 연도 조각에 업체 없음."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from helper_plan_file import year_line_plan
|
||||
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Network as network
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
|
||||
drawn = year_line_plan({2026: [[0.0, 0.0], [100.0, 0.0]], 2027: [[100.0, 0.0], [100.0, 50.0]]})
|
||||
plans = {"빈": store.empty_plan(), "그음": drawn, "그음2": drawn}
|
||||
prints = network.year_rows(store.plan_units(drawn))
|
||||
|
||||
async def load(pid):
|
||||
return Path("."), plans[pid]
|
||||
|
||||
async def status(pid):
|
||||
return {2026: {"fingerprint": prints[2026]["fingerprint"], "confirmed_at": "x"}}
|
||||
|
||||
monkeypatch.setattr(store, "load_plan", load)
|
||||
monkeypatch.setattr(store, "year_status", status)
|
||||
|
||||
class Cursor(_Cursor):
|
||||
async def fetchall(self):
|
||||
return [
|
||||
{"id": "빈", "piece_id": None},
|
||||
{"id": "그음", "piece_id": None},
|
||||
{"id": "그음2", "piece_id": "p26"}, # 확정 2026 에 업체 있음
|
||||
]
|
||||
|
||||
out = asyncio.run(home._forest(Cursor(), 4))
|
||||
assert out == {"plan_status": {"draft": 1, "analyzed": 2, "confirmed": 2}, "assign_waiting": 1}
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
- 확정 메일 = 업체마다 자기 조각 zip(남의 조각 안 샘)
|
||||
- 업체 저장 = 조각 id 검사 · 연도는 조각에서 · 같은 업체 두 조각 = 두 행
|
||||
- 연결 · 가져오기 = 조각 꺾은선 shp · 받은 목록 = 조각 이름 · 길이
|
||||
- 노선 · 조각 · 연도 = 설계 내용 파일(PLAN 590-1 · `helper_plan_file`)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
@@ -28,6 +28,8 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from helper_plan_file import plan_of, use_plan # noqa: E402
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata # noqa: E402
|
||||
from D01_PlanRegister import D01_PlanRegister_Router as d01 # noqa: E402
|
||||
from D04_PlanConfirm import D04_PlanConfirm_Link as link # noqa: E402
|
||||
@@ -45,28 +47,21 @@ FIRM5 = {"role": "USER", "email": "a@ga.kr", "company_id": 5, "user_id": 3}
|
||||
FIRM6 = {"role": "USER", "email": "b@na.kr", "company_id": 6, "user_id": 4}
|
||||
|
||||
|
||||
def _piece(pid, line, name, year, start, end, planned):
|
||||
return {
|
||||
"piece_id": pid,
|
||||
"line_id": line,
|
||||
"line_name": name,
|
||||
"year": year,
|
||||
"from_m": start,
|
||||
"to_m": end,
|
||||
"length_m": end - start,
|
||||
"epsg": 5186,
|
||||
"route_json": {"planned": planned, "stations": [start, end]},
|
||||
}
|
||||
|
||||
|
||||
PIECES = [
|
||||
_piece("p1", "main", "본선", 2026, 0.0, 250.0, P1),
|
||||
_piece("p3", "b1", "갈래 1", 2026, 0.0, 130.0, P3),
|
||||
_piece("p2", "main", "본선", 2027, 250.0, 250.0 + L2, P2),
|
||||
LINES = [
|
||||
{"id": "main", "vertices": [P1[0], P1[1], P2[1]], "branch": None},
|
||||
{"id": "b1", "vertices": P3, "branch": {"line": "main", "vertex": 1}},
|
||||
]
|
||||
MARKS = [{"line": "main", "at_m": 250.0}]
|
||||
PIECES = [
|
||||
{"id": "p1", "line": "main", "from_m": 0.0, "to_m": 250.0, "year": 2026},
|
||||
{"id": "p3", "line": "b1", "from_m": 0.0, "to_m": 130.0, "year": 2026},
|
||||
{"id": "p2", "line": "main", "from_m": 250.0, "to_m": 250.0 + L2, "year": 2027},
|
||||
]
|
||||
PLAN = plan_of(LINES, MARKS, PIECES)
|
||||
|
||||
|
||||
def _assignee(aid, piece, year, company, name, email, notified=None):
|
||||
"""DB 업체 줄 — `year` 는 낡은 사본(읽지 않음 · 연도 = 파일 조각)."""
|
||||
return {
|
||||
"id": aid,
|
||||
"year": year,
|
||||
@@ -82,13 +77,10 @@ def _assignee(aid, piece, year, company, name, email, notified=None):
|
||||
@pytest.fixture
|
||||
def plan(monkeypatch):
|
||||
state = {
|
||||
"years": [
|
||||
{"year": 2026, "epsg": 5186, "route_json": {"planned": P1}, "confirmed_at": "x"},
|
||||
{"year": 2027, "epsg": 5186, "route_json": {"planned": P2}, "confirmed_at": None},
|
||||
],
|
||||
"file": use_plan(monkeypatch, PLAN, {2026: "x"}),
|
||||
"assignees": [
|
||||
_assignee(7, "p1", 2026, 5, "가", "a@ga.kr"),
|
||||
_assignee(8, "p3", 2026, 6, "나", "b@na.kr"),
|
||||
_assignee(8, "p3", 2029, 6, "나", "b@na.kr"), # 낡은 연도 사본(요구 452 ⑤)
|
||||
],
|
||||
"mails": [],
|
||||
"applied": None,
|
||||
@@ -97,12 +89,6 @@ def plan(monkeypatch):
|
||||
async def project(_pid):
|
||||
return {"id": PLAN_ID, "name": "시험", "region": "일월.용화", "project_kind": "PLAN"}
|
||||
|
||||
async def years(_pid):
|
||||
return state["years"]
|
||||
|
||||
async def pieces(_pid):
|
||||
return PIECES
|
||||
|
||||
async def assignees(_pid):
|
||||
return state["assignees"]
|
||||
|
||||
@@ -120,8 +106,6 @@ def plan(monkeypatch):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(repo, "get_plan_project", project)
|
||||
monkeypatch.setattr(repo, "list_year_routes", years)
|
||||
monkeypatch.setattr(repo, "list_pieces", pieces)
|
||||
monkeypatch.setattr(repo, "list_assignees", assignees)
|
||||
monkeypatch.setattr(repo, "mark_confirmed", noop)
|
||||
monkeypatch.setattr(repo, "mark_notified", noop)
|
||||
@@ -146,6 +130,7 @@ def test_summary_piece_rows_and_firm_view(plan):
|
||||
assert rows[1]["length_m"] == 130.0 and rows[1]["start"]["x"] == P3[0][0]
|
||||
assert rows[0]["confirmed_at"] == "x" and rows[2]["confirmed_at"] is None
|
||||
assert [y["year"] for y in out["years"]] == [2026, 2027]
|
||||
assert [(a["id"], a["year"]) for a in out["assignees"]] == [(7, 2026), (8, 2026)]
|
||||
# 같은 연도 다른 업체 — 자기 조각 · 자기 행만
|
||||
for session, piece, aid in ((FIRM5, "p1", 7), (FIRM6, "p3", 8)):
|
||||
mine = asyncio.run(d04.get_plan_summary(PLAN_ID, session))
|
||||
@@ -178,7 +163,7 @@ def test_export_piece_folders_and_firm_only_own(plan):
|
||||
]
|
||||
firm = _names(asyncio.run(d04.export_plan(PLAN_ID, FIRM6)))
|
||||
assert {n.split("/")[0] for n in firm} == {"2026_갈래1_p3"}
|
||||
plan["years"][0]["confirmed_at"] = None # 확정 전 = 받을 것 없음
|
||||
plan["file"]["confirmed"].clear() # 확정 전 = 받을 것 없음
|
||||
with pytest.raises(HTTPException) as err:
|
||||
asyncio.run(d04.export_plan(PLAN_ID, FIRM6))
|
||||
assert err.value.status_code == 403
|
||||
@@ -218,41 +203,30 @@ def test_put_assignees_by_piece(plan):
|
||||
assert [(i["piece_id"], i["year"], i["company_id"]) for i in inserts] == [("p3", 2026, 5)]
|
||||
|
||||
|
||||
def test_legacy_year_assignee_sits_on_first_piece(plan):
|
||||
"""조각이 생기기 전 연도 지정(piece_id 없음) — 그 연도 첫 조각에 붙어 보임."""
|
||||
plan["assignees"] = [_assignee(9, None, 2026, 5, "가", "a@ga.kr")]
|
||||
mine = asyncio.run(d04.get_plan_summary(PLAN_ID, FIRM5))
|
||||
assert [r["piece_id"] for r in mine["pieces"]] == ["p1"]
|
||||
def test_confirm_drops_when_file_changes(plan, monkeypatch):
|
||||
"""파일만 바뀌고 연도 상태 지문이 낡으면 확정으로 안 보임(파일 쓰기 · DB 커밋 틈)."""
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
|
||||
|
||||
async def stale(_pid):
|
||||
return {2026: {"fingerprint": "낡음", "confirmed_at": "x"}}
|
||||
|
||||
monkeypatch.setattr(store, "year_status", stale)
|
||||
out = asyncio.run(d04.get_plan_summary(PLAN_ID, FOREST))
|
||||
assert all(r["confirmed_at"] is None for r in out["pieces"])
|
||||
|
||||
|
||||
def test_link_uses_piece_route():
|
||||
row = {
|
||||
"id": 7,
|
||||
"year": 2026,
|
||||
"piece_id": "p3",
|
||||
"piece_epsg": 5186,
|
||||
"piece_json": json.dumps({"planned": P3, "stations": [0.0, 130.0]}),
|
||||
"from_m": 0.0,
|
||||
"to_m": 130.0,
|
||||
"piece_length_m": 130.0,
|
||||
"epsg": 5186,
|
||||
"route_json": json.dumps({"planned": P1, "length_m": 380.0}),
|
||||
"name": "2026 홍천 노선 계획",
|
||||
"region": "일월.용화",
|
||||
}
|
||||
unit = link.unit_row(row)
|
||||
def test_link_uses_piece_route(plan):
|
||||
unit = asyncio.run(link.piece_route(PLAN_ID, "p3"))
|
||||
assert unit["year"] == 2026 and unit["epsg"] == 5186 and unit["confirmed_at"] == "x"
|
||||
assert unit["route_json"]["planned"] == P3 and unit["route_json"]["length_m"] == 130.0
|
||||
assert "piece_json" not in unit and "from_m" not in unit
|
||||
assert link.route_fingerprint(unit["route_json"]) != link.route_fingerprint(row["route_json"])
|
||||
files = link.year_files(unit)
|
||||
files = link.year_files({**unit, "name": "2026 홍천 노선 계획", "region": "일월.용화"})
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for name, data in files.items():
|
||||
(Path(tmp) / name).write_bytes(data)
|
||||
meta = analyze_input_metadata(next(Path(tmp).glob("*.shp")))
|
||||
assert meta["point_count"] == 2 and float(meta["attributes"]["종점누가"]) == 130.0
|
||||
# 옛 연도 지정 = 연도 노선 그대로
|
||||
legacy = link.unit_row({**row, "piece_id": None})
|
||||
assert legacy["route_json"] == row["route_json"] and legacy["epsg"] == 5186
|
||||
gone = asyncio.run(link.piece_route(PLAN_ID, "없음"))
|
||||
assert gone["route_json"] is None and gone["year"] is None
|
||||
|
||||
|
||||
class _Cursor:
|
||||
@@ -289,43 +263,20 @@ class _Conn:
|
||||
return False
|
||||
|
||||
|
||||
def test_assigned_list_piece_label_length(monkeypatch):
|
||||
rows = [
|
||||
{
|
||||
"assignee_id": 8,
|
||||
"project_id": PLAN_ID,
|
||||
"project_name": "시험",
|
||||
"year": 2026,
|
||||
"piece_id": "p3",
|
||||
"piece_length_m": 130.0,
|
||||
"linked_project_id": None,
|
||||
"linked_project_name": None,
|
||||
"source_fingerprint": None,
|
||||
"route_json": json.dumps({"planned": P3}),
|
||||
"confirmed_at": "x",
|
||||
},
|
||||
{
|
||||
"assignee_id": 9,
|
||||
"project_id": PLAN_ID,
|
||||
"project_name": "옛",
|
||||
"year": 2025,
|
||||
"piece_id": None,
|
||||
"piece_length_m": None,
|
||||
"linked_project_id": None,
|
||||
"linked_project_name": None,
|
||||
"source_fingerprint": None,
|
||||
"route_json": json.dumps({"planned": P1, "length_m": 250.0}),
|
||||
"confirmed_at": "x",
|
||||
},
|
||||
]
|
||||
|
||||
async def pieces(_pid):
|
||||
return PIECES
|
||||
|
||||
def test_assigned_list_piece_label_length(plan, monkeypatch):
|
||||
"""연도 · 길이 · 이름 = 파일 조각 · 확정 안 되고 연결도 없는 줄(2027 · 없는 조각)은 뺌."""
|
||||
row = {
|
||||
"assignee_id": 8,
|
||||
"project_id": PLAN_ID,
|
||||
"project_name": "시험",
|
||||
"piece_id": "p3",
|
||||
"linked_project_id": None,
|
||||
"linked_project_name": None,
|
||||
"source_fingerprint": None,
|
||||
}
|
||||
rows = [row, {**row, "assignee_id": 9, "piece_id": "p2"}, {**row, "piece_id": "없음"}]
|
||||
monkeypatch.setattr(d01, "get_db_pool", lambda: _Conn(rows))
|
||||
monkeypatch.setattr(repo, "list_pieces", pieces)
|
||||
out = asyncio.run(d01.assigned_plan_routes(FIRM6))["routes"]
|
||||
assert [(r["piece_id"], r["piece_label"], r["length_m"]) for r in out] == [
|
||||
("p3", "갈래 1 0+0~6+10", 130.0),
|
||||
(None, None, 250.0),
|
||||
assert [(r["piece_id"], r["year"], r["piece_label"], r["length_m"]) for r in out] == [
|
||||
("p3", 2026, "갈래 1 0+0~6+10", 130.0),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""590-1 예정노선 설계 내용 = 프로젝트 폴더 한 파일(요구 452 · 453) — 지킴 시험.
|
||||
|
||||
- 옮김 = 옛 DB 칸(지점 points · starts/ends · start/end · 빈 칸 = 연도 행 접기 · 노선 정본 ·
|
||||
옛 연도 노선) → 새 꼴 한 벌(좌표계 머리 한 곳 · 빈 연도 없음)
|
||||
- 원자 쓰기 = 바꿔치기 실패에도 옛 파일 그대로 · 임시 파일 안 남음 · 새 판 파일 거절
|
||||
- 연도 읽기 = 업체 줄 연도는 파일 조각에서(낡은 DB 연도 무시 · ⑤) · 확정 = 지문 같을 때만
|
||||
- 잠금 = 같은 프로젝트 [저장] 둘이 겹치지 않음
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_590_1_plan_file.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from helper_plan_file import use_plan, year_line_plan # noqa: E402
|
||||
|
||||
from common_util.common_util_crs import lonlat_to_tm # noqa: E402
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Network as net # noqa: E402
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store # noqa: E402
|
||||
from D04_PlanConfirm import D04_PlanConfirm_Router as d04 # noqa: E402
|
||||
|
||||
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
|
||||
A = {"x": 210000.0, "y": 380000.0}
|
||||
B = {"x": 210300.0, "y": 380400.0}
|
||||
NETWORK = {
|
||||
"epsg": 5186,
|
||||
"lines": [{"id": "main", "vertices": [[0.0, 0.0], [100.0, 0.0]], "branch": None}],
|
||||
"marks": [{"line": "main", "at_m": 40.0}],
|
||||
"pieces": [
|
||||
{"id": "p1", "line": "main", "from_m": 0.0, "to_m": 40.0, "year": 2027},
|
||||
{"id": "p2", "line": "main", "from_m": 40.0, "to_m": 100.0, "year": 2029},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# --- 옮김 ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base",
|
||||
[
|
||||
{"epsg": 5186, "points": [A, B]},
|
||||
{"epsg": 5186, "starts": [A], "ends": [B]},
|
||||
{"epsg": 5186, "start": A, "end": B},
|
||||
],
|
||||
)
|
||||
def test_migrate_old_point_forms(base):
|
||||
plan = store.migrate(json.dumps(base), json.dumps(NETWORK), [])
|
||||
assert plan["points"] == [A, B] and plan["epsg"] == 5186
|
||||
assert plan["lines"] == NETWORK["lines"] and plan["pieces"] == NETWORK["pieces"]
|
||||
assert set(plan) == {"version", "epsg", "points", "lines", "marks", "pieces"}
|
||||
|
||||
|
||||
def test_migrate_folds_year_rows_and_legacy_route():
|
||||
rows = [
|
||||
{"year": 2027, "epsg": 5186, "route_json": None,
|
||||
"start_x": 1.0, "start_y": 2.0, "end_x": 3.0, "end_y": 4.0},
|
||||
{"year": 2026, "epsg": 5186, "route_json": json.dumps({"planned": [[0, 0], [30, 40]]}),
|
||||
"start_x": 0.0, "start_y": 0.0, "end_x": 1.0, "end_y": 2.0},
|
||||
{"year": 2028, "epsg": None, "route_json": None,
|
||||
"start_x": None, "start_y": None, "end_x": None, "end_y": None}, # 빈 연도 줄
|
||||
] # fmt: skip
|
||||
plan = store.migrate(None, None, rows)
|
||||
assert plan["points"] == [{"x": 0.0, "y": 0.0}, {"x": 3.0, "y": 4.0}] # 첫 시점 · 끝 종점
|
||||
assert [p["year"] for p in plan["pieces"]] == [2026] # 빈 연도는 조각이 아님
|
||||
assert store.plan_units(plan)[0]["length_m"] == 50.0
|
||||
assert store.migrate(None, None, []) == store.empty_plan()
|
||||
|
||||
|
||||
def test_migrate_one_epsg_converts_points():
|
||||
lon_lat = (127.1, 37.5)
|
||||
x, y = lonlat_to_tm(*lon_lat, 5185)
|
||||
plan = store.migrate(
|
||||
json.dumps({"epsg": 5185, "points": [{"x": x, "y": y}]}), json.dumps(NETWORK), []
|
||||
)
|
||||
assert plan["epsg"] == 5186
|
||||
assert (plan["points"][0]["x"], plan["points"][0]["y"]) == pytest.approx(
|
||||
lonlat_to_tm(*lon_lat, 5186), abs=1e-3
|
||||
)
|
||||
|
||||
|
||||
# --- 원자 쓰기 · 판 ---
|
||||
|
||||
|
||||
def test_atomic_write_keeps_old_file_on_failure(tmp_path, monkeypatch):
|
||||
plan = {**store.empty_plan(5186), **NETWORK}
|
||||
store.save_plan(tmp_path, plan)
|
||||
path = store.plan_path(tmp_path)
|
||||
assert (
|
||||
path.parent.name == "D03_PlanAnalysis"
|
||||
and json.loads(path.read_text("utf-8"))["version"] == 1
|
||||
)
|
||||
assert store.read_plan(tmp_path) == plan
|
||||
|
||||
import common_util.common_util_json as cj
|
||||
|
||||
def broken(source, target):
|
||||
raise PermissionError("잡힘")
|
||||
|
||||
monkeypatch.setattr(cj, "replace_with_retry", broken)
|
||||
with pytest.raises(PermissionError):
|
||||
store.save_plan(tmp_path, {**plan, "lines": [], "marks": [], "pieces": []})
|
||||
assert store.read_plan(tmp_path) == plan # 옛 파일 그대로
|
||||
assert [p.name for p in path.parent.iterdir()] == ["plan_network.json"] # 임시 파일 안 남음
|
||||
|
||||
|
||||
def test_newer_version_refused(tmp_path):
|
||||
path = store.plan_path(tmp_path)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps({**store.empty_plan(), "version": 2}), "utf-8")
|
||||
with pytest.raises(HTTPException) as err:
|
||||
store.read_plan(tmp_path)
|
||||
assert err.value.status_code == 409
|
||||
assert store.read_plan(tmp_path / "없음") is None
|
||||
|
||||
|
||||
# --- 연도 읽기(⑤) ---
|
||||
|
||||
|
||||
def test_assignee_year_comes_from_file(monkeypatch):
|
||||
use_plan(monkeypatch, {**store.empty_plan(5186), **NETWORK})
|
||||
_, units = asyncio.run(d04._units(PID))
|
||||
stale = [{"id": 27, "piece_id": "p1", "year": 2028}, {"id": 28, "piece_id": "p2", "year": 2027}]
|
||||
assert [a["year"] for a in d04.with_years(stale, units)] == [2027, 2029]
|
||||
assert d04.with_years([{"id": 1, "piece_id": "없음", "year": 2027}], units)[0]["year"] is None
|
||||
|
||||
|
||||
def test_confirmed_only_when_fingerprint_matches(monkeypatch):
|
||||
plan = year_line_plan({2026: [[0.0, 0.0], [10.0, 0.0]], 2027: [[10.0, 0.0], [10.0, 10.0]]})
|
||||
state = use_plan(monkeypatch, plan, {2026: "x"})
|
||||
years = asyncio.run(store.plan_state(PID))["years"]
|
||||
assert years[2026]["confirmed_at"] == "x" and years[2027]["confirmed_at"] is None
|
||||
moved = json.loads(json.dumps(plan))
|
||||
moved["lines"][0]["vertices"][0] = [20.0, 0.0] # 2026 조각 모양 바뀜(길이 같음)
|
||||
|
||||
async def stale(_pid): # DB 지문 = 옛 파일 것
|
||||
return {2026: {"fingerprint": years[2026]["fingerprint"], "confirmed_at": "x"}}
|
||||
|
||||
monkeypatch.setattr(store, "year_status", stale)
|
||||
state["plan"] = moved
|
||||
assert asyncio.run(store.plan_state(PID))["years"][2026]["confirmed_at"] is None
|
||||
|
||||
|
||||
# --- 잠금 ---
|
||||
|
||||
|
||||
def test_two_saves_do_not_interleave(monkeypatch, tmp_path):
|
||||
order: list[str] = []
|
||||
|
||||
async def load(_pid):
|
||||
return tmp_path, store.read_plan(tmp_path) or store.empty_plan()
|
||||
|
||||
async def year_start(_pid):
|
||||
return 2026
|
||||
|
||||
async def slow_status(connection, project_id, pieces, years):
|
||||
order.append(f"start {len(pieces)}")
|
||||
await asyncio.sleep(0.05)
|
||||
order.append(f"end {len(pieces)}")
|
||||
|
||||
class Conn:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def begin(self):
|
||||
pass
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def rollback(self):
|
||||
pass
|
||||
|
||||
class Pool:
|
||||
def acquire(self):
|
||||
return Conn()
|
||||
|
||||
monkeypatch.setattr(store, "load_plan", load)
|
||||
monkeypatch.setattr(net, "_year_start", year_start)
|
||||
monkeypatch.setattr(net, "_store_status", slow_status)
|
||||
monkeypatch.setattr(net, "get_db_pool", lambda: Pool())
|
||||
forest = {"role": "SYSTEM_ADMIN", "email": "a@b.kr"}
|
||||
one = net.Network(**{**NETWORK, "pieces": NETWORK["pieces"][:1]})
|
||||
two = net.Network(**NETWORK)
|
||||
|
||||
async def both():
|
||||
from uuid import UUID
|
||||
|
||||
await asyncio.gather(
|
||||
net.put_plan_network(UUID(PID), one, forest),
|
||||
net.put_plan_network(UUID(PID), two, forest),
|
||||
)
|
||||
|
||||
asyncio.run(both())
|
||||
assert order == ["start 1", "end 1", "start 2", "end 2"]
|
||||
assert len(store.read_plan(tmp_path)["pieces"]) == 2 # 뒤 저장이 이김 · 반쪽 없음
|
||||
Reference in New Issue
Block a user