- 옮김 끝 — 파일 없는 PLAN 10 개 = 옛 칸 → plan_network.json 한 번(일회 스크립트 · 11 개 모두 파일 대 DB 대조 같음) - 저장 모듈 = 파일 · 없으면 빈 설계(옛 칸 · 표 안 읽음) · 옛 연도 노선 접기(legacy_network) · 응답 legacy 칸 걷음 - D04 조각 노선 열쇠 route_json → route(옛 칸 이름과 헷갈림 걷음 · 메모리 값) - 복제 도구 = 옛 D 표 안 뜸 · 예정노선 설계는 폴더 파일로 따라감 - 024_plan_site_fill.py 지움 — 옛 칸을 읽는 일회 도구 · 기준점 11 개 모두 채움 - SQL 025 = 단계 번호(PLAN_BASE 걷음) · plan_networks · plan_route_pieces · projects.plan_base_json · 연도 줄 노선 칸 · 업체 줄 year 지움 — 공유 DB 적용 안 함(데스크탑 임시 배포가 새 코드를 받은 뒤 · 임시 DB 두 번 돌림 확인) - 시험: 590_1 옮김 시험 셋 → 파일 없음 = 빈 설계 · 코드에 옛 칸 이름 0 · 530_2 옛 연도 노선 시험 지움(그 길 걷음) · 591_2 옛 지점 꼴 시험 지움 · 591_1 채움 도구 시험 지움(도구 지움) · 573_1 = 파일째 따라감 · 옛 표 목록 밖 허용(025 전) · 531_3 · 563_2 · 593 = 열쇠 이름 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
7.3 KiB
Python
217 lines
7.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""590-1 예정노선 설계 내용 = 프로젝트 폴더 한 파일(요구 452 · 453) — 지킴 시험.
|
|
|
|
- 파일 없음 = 빈 설계 · 옛 DB 칸(590-3 에서 옮김 끝) 안 읽음 · 코드에 옛 칸 이름 0
|
|
- 원자 쓰기 = 바꿔치기 실패에도 옛 파일 그대로 · 임시 파일 안 남음 · 새 판 파일 거절
|
|
- 연도 읽기 = 업체 줄 연도는 파일 조각에서(낡은 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 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},
|
|
],
|
|
}
|
|
|
|
|
|
# --- 옛 DB 칸 안 읽음(590-3) ---
|
|
|
|
OLD_NAMES = ("plan_base_json", "plan_networks", "plan_route_pieces", "route_json", "start_x")
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
class _Pool:
|
|
"""가짜 DB — 프로젝트 행 하나 · 부른 SQL 기록."""
|
|
|
|
def __init__(self):
|
|
self.sqls = []
|
|
|
|
def acquire(self):
|
|
return self
|
|
|
|
def cursor(self, *_):
|
|
return self
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_):
|
|
return False
|
|
|
|
async def execute(self, sql, args=()):
|
|
self.sqls.append(sql)
|
|
|
|
async def fetchone(self):
|
|
return {"project_kind": "PLAN", "project_year": 2027, "storage_path": "x"}
|
|
|
|
|
|
def test_no_file_is_empty_plan(tmp_path, monkeypatch):
|
|
pool = _Pool()
|
|
monkeypatch.setattr(store, "get_db_pool", lambda: pool)
|
|
monkeypatch.setattr(store, "resolve_stored_project_path", lambda _p: str(tmp_path))
|
|
root, plan = asyncio.run(store.load_plan(PID))
|
|
assert root == tmp_path and plan == store.empty_plan()
|
|
assert len(pool.sqls) == 1 and not any(n in pool.sqls[0] for n in OLD_NAMES)
|
|
assert not store.plan_path(tmp_path).exists() # 읽기만 = 안 씀
|
|
|
|
|
|
def test_code_has_no_old_db_names():
|
|
"""옛 설계 칸 · 표 이름 = 코드 0(지움 SQL 025 · 지난 SQL · 시험 빼고)."""
|
|
dirs = [p for p in ROOT.iterdir() if p.is_dir() and p.name[:3] in {"B01", "D01", "D03", "D04"}]
|
|
files = [f for d in dirs for f in d.rglob("*") if f.suffix in {".py", ".ts"}]
|
|
files += [*(ROOT / "common_util").glob("*.py"), *(ROOT / "db_management").glob("*.py")]
|
|
hits = [
|
|
f"{f.relative_to(ROOT)}:{n}"
|
|
for f in files
|
|
for n in OLD_NAMES
|
|
if n in f.read_text(encoding="utf-8")
|
|
]
|
|
assert not hits, hits
|
|
|
|
|
|
# --- 원자 쓰기 · 판 ---
|
|
|
|
|
|
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 project_info(_pid):
|
|
return {"year_start": 2026, "site": None}
|
|
|
|
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, "project_info", project_info)
|
|
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 # 뒤 저장이 이김 · 반쪽 없음
|