Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-30 09:53:28 +09:00
18 changed files with 1032 additions and 798 deletions
+56
View File
@@ -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)
+36 -22
View File
@@ -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
+53 -42
View File
@@ -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
+64 -117
View File
@@ -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] # 빠진 업체 지움
+38 -1
View File
@@ -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}
+48 -97
View File
@@ -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),
]
+209
View File
@@ -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 # 뒤 저장이 이김 · 반쪽 없음