- common_util_design_sets: 활성 벌 = 제자리 · 쉬는 벌 = design_sets/<las|sheet>/(db.json · files/ · initial_snapshot/ · meta.json) - 벌 몫 = B05 노선 정본 · 코리도 · B06~B09 · 배수 노선 몫 · 초기값 · 공유 캐시(비용면 · 예정노선 · 하강장 · 도엽 좌표 · 강우)는 안 옮김 · 안 지움 - 옮김 = 같은 볼륨 이름 바꿈 · 단계 2 이후 행 · projects.status 함께 - 묶음 없음 · 지문 어긋남 = 그 자리에서 자동 설계 체인 한 번(inner = 마커 · 미리 셈은 부르는 쪽) - 시험 test_765_design_sets 6 (왕복 값 같음 · 새 id · 묵은 벌 · 실패 되돌림) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
414 lines
16 KiB
Python
414 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""765-2 지형마다 설계 한 벌 — 떠 두기 · 꺼내 끼우기 · 맞바꿈(`common_util_design_sets`).
|
|
|
|
DB = 메모리 sqlite 를 aiomysql 꼴로 감싼 가짜(노선 · 자식 넷 · 단계 · 프로젝트 · 지표면 모델) ·
|
|
storage = 시험 안 임시 폴더. 지킴:
|
|
① 떠 둔 뒤 꺼내면 값 같음 — 노선 · 자식 · 단계 · 상태 · 벌 몫 파일 · 초기값 · 코리도
|
|
② 꺼낼 때 새 노선 id · 자식 다시 잇기 · 코리도 이름 새 id
|
|
③ 공유 캐시(비용면 · 예정노선 · 배수 하강장 · 도엽 좌표 · 강우)는 안 옮기고 안 지움
|
|
④ 지문 어긋난(묵은) 벌은 버리고 그 자리에서 지음 · 실패면 통째 되돌림
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
import aiomysql # noqa: E402
|
|
|
|
from common_util import common_util_design_sets as sets # noqa: E402
|
|
|
|
PID = str(uuid4())
|
|
|
|
_SCHEMA = """
|
|
CREATE TABLE projects (id TEXT PRIMARY KEY, status TEXT, name TEXT);
|
|
CREATE TABLE surface_models (id INTEGER PRIMARY KEY, project_id TEXT, status TEXT);
|
|
CREATE TABLE project_workflow_stages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, stage_no INT, stage_key TEXT,
|
|
state TEXT, progress_percent INT, params TEXT, message TEXT, started_at TEXT, completed_at TEXT);
|
|
CREATE TABLE routes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, surface_model_id INT, status TEXT,
|
|
total_length_m REAL, route_data_path TEXT, created_at TEXT);
|
|
CREATE TABLE route_points (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
route_id INT REFERENCES routes(id) ON DELETE CASCADE, chainage_m REAL, sequence_num INT);
|
|
CREATE TABLE route_statistics (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
route_id INT REFERENCES routes(id) ON DELETE CASCADE, cut_volume_m3 REAL);
|
|
CREATE TABLE longitudinal_sections (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT,
|
|
route_id INT REFERENCES routes(id) ON DELETE CASCADE, data TEXT);
|
|
CREATE TABLE cross_sections (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT,
|
|
route_id INT REFERENCES routes(id) ON DELETE CASCADE, chainage_m REAL, data TEXT);
|
|
"""
|
|
|
|
|
|
class _Cursor:
|
|
def __init__(self, db: sqlite3.Connection, dict_rows: bool) -> None:
|
|
self.db, self.dict_rows, self.rows, self.lastrowid, self.rowcount = db, dict_rows, [], 0, 0
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_):
|
|
return False
|
|
|
|
@staticmethod
|
|
def _sql(sql: str) -> str:
|
|
return re.sub(r"%s", "?", sql)
|
|
|
|
async def execute(self, sql: str, args=()) -> None:
|
|
cur = self.db.execute(self._sql(sql), tuple(args or ()))
|
|
self.lastrowid, self.rowcount = cur.lastrowid, cur.rowcount
|
|
names = [d[0] for d in cur.description or []]
|
|
rows = cur.fetchall()
|
|
self.rows = [dict(zip(names, r)) for r in rows] if self.dict_rows else rows
|
|
|
|
async def executemany(self, sql: str, seq) -> None:
|
|
self.db.executemany(self._sql(sql), [tuple(a) for a in seq])
|
|
|
|
async def fetchall(self):
|
|
return self.rows
|
|
|
|
async def fetchone(self):
|
|
return self.rows[0] if self.rows else None
|
|
|
|
|
|
class _Conn:
|
|
def __init__(self, db: sqlite3.Connection) -> None:
|
|
self.db = db
|
|
|
|
def cursor(self, kind=None):
|
|
return _Cursor(self.db, kind is aiomysql.DictCursor)
|
|
|
|
async def begin(self):
|
|
pass
|
|
|
|
async def commit(self):
|
|
self.db.commit()
|
|
|
|
async def rollback(self):
|
|
self.db.rollback()
|
|
|
|
|
|
class _Pool:
|
|
def __init__(self, conn: _Conn) -> None:
|
|
self.conn = conn
|
|
|
|
def acquire(self):
|
|
conn = self.conn
|
|
|
|
class _Ctx:
|
|
async def __aenter__(self):
|
|
return conn
|
|
|
|
async def __aexit__(self, *_):
|
|
return False
|
|
|
|
return _Ctx()
|
|
|
|
|
|
def _seed_db() -> sqlite3.Connection:
|
|
db = sqlite3.connect(":memory:")
|
|
db.execute("PRAGMA foreign_keys = ON")
|
|
db.executescript(_SCHEMA)
|
|
db.execute("INSERT INTO projects VALUES (?, 'WF3_ANALYZING', 'p')", (PID,))
|
|
db.executemany(
|
|
"INSERT INTO surface_models VALUES (?, ?, ?)",
|
|
[(5, PID, "CONFIRMED"), (6, PID, "COMPLETE")],
|
|
)
|
|
for no in range(7):
|
|
db.execute(
|
|
"INSERT INTO project_workflow_stages (project_id, stage_no, stage_key, state,"
|
|
" progress_percent, params, message) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(PID, no, f"S{no}", "IN_PROGRESS", 50, json.dumps({"no": no, "k": "las"}), f"m{no}"),
|
|
)
|
|
for rid in (10, 11):
|
|
db.execute(
|
|
"INSERT INTO routes (id, project_id, surface_model_id, status, total_length_m,"
|
|
" route_data_path, created_at) VALUES (?, ?, 5, 'CONFIRMED', ?, 'r.geojson', 't')",
|
|
(rid, PID, rid * 100.0),
|
|
)
|
|
for seq in range(3):
|
|
db.execute(
|
|
"INSERT INTO route_points (route_id, chainage_m, sequence_num) VALUES (?, ?, ?)",
|
|
(rid, seq * 20.0, seq),
|
|
)
|
|
db.execute(
|
|
"INSERT INTO cross_sections (project_id, route_id, chainage_m, data)"
|
|
" VALUES (?, ?, ?, ?)",
|
|
(PID, rid, seq * 20.0, json.dumps({"cut": seq})),
|
|
)
|
|
db.execute("INSERT INTO route_statistics (route_id, cut_volume_m3) VALUES (?, 7)", (rid,))
|
|
db.execute(
|
|
"INSERT INTO longitudinal_sections (project_id, route_id, data) VALUES (?, ?, '{}')",
|
|
(PID, rid),
|
|
)
|
|
db.commit()
|
|
return db
|
|
|
|
|
|
_SET_FILES = {
|
|
"B05_Profile/route/planned_route.csv": "x,y\n1,2\n",
|
|
"B05_Profile/route/structures.json": '{"items": [1]}',
|
|
"B05_Profile/route/route_main.geojson": "{}",
|
|
"B05_Profile/corridor/corridor_0011.json": '{"route": 11}',
|
|
"B06_Section/longitudinal/longitudinal.json": '{"z": [1, 2]}',
|
|
"B06_Section/sampling.json": '{"surface_model_id": 5}',
|
|
"B04_PreProcess/drainage/01_primary_region.geojson": "{}",
|
|
"B04_PreProcess/drainage/edits/pipe_points.json": "[1]",
|
|
"B04_PreProcess/drainage/manifest.json": "{}",
|
|
"initial_snapshot/db.json": '{"routes": []}',
|
|
}
|
|
_SHARED_FILES = {
|
|
"B05_Profile/route/cost_surface_x_implicit.npz": "c",
|
|
"B05_Profile/route/expected_route.csv": "e",
|
|
"B05_Profile/route/initial_choice.json": "{}",
|
|
"B04_PreProcess/drainage/descent_ab_cd.npz": "d",
|
|
"B04_PreProcess/drainage/00_sheet_metric.pkl": "s",
|
|
"B04_PreProcess/drainage/rainfall_table.json": "r",
|
|
"B04_PreProcess/models/dtm_x.npz": "m",
|
|
}
|
|
|
|
|
|
def _seed_files(root: Path) -> None:
|
|
for relative, text in {**_SET_FILES, **_SHARED_FILES}.items():
|
|
path = root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
|
|
|
|
def _tree(root: Path) -> dict[str, str]:
|
|
return {
|
|
p.relative_to(root).as_posix(): p.read_text(encoding="utf-8")
|
|
for p in sorted(root.rglob("*"))
|
|
if p.is_file() and SETS_DIR not in p.relative_to(root).parts
|
|
}
|
|
|
|
|
|
SETS_DIR = sets.SETS_DIRNAME
|
|
FP = {
|
|
"terrain": "las",
|
|
"source_filter": "classification",
|
|
"method": "implicit",
|
|
"smooth": False,
|
|
"model_id": 5,
|
|
"model_digest": "aa",
|
|
}
|
|
|
|
|
|
def _rows(db: sqlite3.Connection, sql: str, args=()) -> list[tuple]:
|
|
return db.execute(sql, args).fetchall()
|
|
|
|
|
|
def _no_ids(db: sqlite3.Connection) -> dict[str, list]:
|
|
"""id 를 뺀 값(자식은 노선 차례로) — 왕복 비교용."""
|
|
order = {rid: i for i, (rid,) in enumerate(_rows(db, "SELECT id FROM routes ORDER BY id"))}
|
|
out = {
|
|
"routes": _rows(
|
|
db,
|
|
"SELECT project_id, surface_model_id, status, total_length_m, route_data_path,"
|
|
" created_at FROM routes ORDER BY id",
|
|
),
|
|
"stages": _rows(
|
|
db,
|
|
"SELECT stage_no, state, progress_percent, params, message FROM"
|
|
" project_workflow_stages ORDER BY stage_no",
|
|
),
|
|
"status": _rows(db, "SELECT status FROM projects"),
|
|
}
|
|
for table, cols in (
|
|
("route_points", "chainage_m, sequence_num"),
|
|
("route_statistics", "cut_volume_m3"),
|
|
("longitudinal_sections", "project_id, data"),
|
|
("cross_sections", "project_id, chainage_m, data"),
|
|
):
|
|
out[table] = sorted(
|
|
(order[r[0]], *r[1:])
|
|
for r in _rows(db, f"SELECT route_id, {cols} FROM {table} ORDER BY id")
|
|
)
|
|
return out
|
|
|
|
|
|
def test_벌_몫과_공유를_가름(tmp_path: Path) -> None:
|
|
_seed_files(tmp_path)
|
|
entries = sets.set_entries(tmp_path)
|
|
assert "B06_Section" in entries and "initial_snapshot" in entries
|
|
assert "B05_Profile/route/structures.json" in entries
|
|
assert "B04_PreProcess/drainage/edits" in entries
|
|
joined = " ".join(entries)
|
|
for shared in ("cost_surface_", "expected_route", "initial_choice", "descent_", "00_sheet"):
|
|
assert shared not in joined
|
|
assert "rainfall_" not in joined and "models" not in joined
|
|
|
|
|
|
def test_떠_두고_꺼내면_값_같음_새_id(tmp_path: Path) -> None:
|
|
db = _seed_db()
|
|
conn = _Conn(db)
|
|
_seed_files(tmp_path)
|
|
before_db, before_files = _no_ids(db), _tree(tmp_path)
|
|
|
|
meta = asyncio.run(sets.park_active_set(conn, tmp_path, PID, FP))
|
|
assert meta["routes"] == [10, 11] and meta["built"]
|
|
assert _rows(db, "SELECT COUNT(*) FROM routes") == [(0,)]
|
|
assert _rows(db, "SELECT COUNT(*) FROM cross_sections") == [(0,)]
|
|
states = _rows(db, "SELECT stage_no, state, params FROM project_workflow_stages ORDER BY 1")
|
|
assert states[:2] == [(0, "IN_PROGRESS", before_db["stages"][0][3])] + [
|
|
(1, "IN_PROGRESS", before_db["stages"][1][3])
|
|
]
|
|
assert all(s[1] == "NOT_STARTED" and s[2] is None for s in states[2:])
|
|
left = _tree(tmp_path)
|
|
assert set(left) == set(_SHARED_FILES) # 공유만 남음 · 그대로
|
|
assert all(left[k] == v for k, v in _SHARED_FILES.items())
|
|
bundle = tmp_path / SETS_DIR / "las"
|
|
assert (bundle / "db.json").is_file() and (bundle / "initial_snapshot" / "db.json").is_file()
|
|
assert (bundle / "files" / "B06_Section" / "sampling.json").is_file()
|
|
assert sets.bundle_ready(tmp_path, "las", FP)
|
|
|
|
route_map = asyncio.run(sets.load_set(conn, tmp_path, PID, "las"))
|
|
assert set(route_map) == {10, 11}
|
|
assert not set(route_map.values()) & {10, 11} # 새 id
|
|
assert _no_ids(db) == before_db # 노선 · 자식 · 단계 · 상태 같음
|
|
after = _tree(tmp_path)
|
|
moved = before_files.pop("B05_Profile/corridor/corridor_0011.json")
|
|
new_corridor = f"B05_Profile/corridor/corridor_{route_map[11]:04d}.json"
|
|
assert after.pop(new_corridor) == moved # 코리도 = 새 id 이름
|
|
assert after == before_files
|
|
assert not bundle.exists() # 꺼낸 묶음은 지움
|
|
|
|
|
|
def test_지문_어긋나면_묵은_벌(tmp_path: Path) -> None:
|
|
db = _seed_db()
|
|
_seed_files(tmp_path)
|
|
asyncio.run(sets.park_active_set(_Conn(db), tmp_path, PID, FP))
|
|
assert sets.bundle_ready(tmp_path, "las", FP)
|
|
assert sets.bundle_ready(tmp_path, "las", {**FP, "model_id": 99}) # 복제 · 갈음 id 는 안 봄
|
|
assert not sets.bundle_ready(tmp_path, "las", {**FP, "model_digest": "bb"})
|
|
assert not sets.bundle_ready(tmp_path, "las", {**FP, "source_filter": "csf"})
|
|
assert not sets.bundle_ready(tmp_path, "las", None)
|
|
assert not sets.bundle_ready(tmp_path, "sheet", {**FP, "terrain": "sheet"})
|
|
sets.note_unbuilt(tmp_path, "sheet", "수치지형도 모델 없음")
|
|
assert sets.read_meta(tmp_path, "sheet") == {
|
|
"terrain": "sheet",
|
|
"built": False,
|
|
"reason": "수치지형도 모델 없음",
|
|
}
|
|
assert not sets.bundle_ready(tmp_path, "sheet", {**FP, "terrain": "sheet"})
|
|
|
|
|
|
def _swap_world(monkeypatch, tmp_path: Path, *, sheet_fp: dict, chain):
|
|
"""las 활성 · sheet 묶음(지문 = sheet_fp) — 칩으로 sheet."""
|
|
import B03_FileInput.B03_FileInput_Service_Chain as chain_mod
|
|
import config.config_db as config_db
|
|
|
|
db = _seed_db()
|
|
conn = _Conn(db)
|
|
_seed_files(tmp_path)
|
|
sheet = {**FP, "terrain": "sheet", "source_filter": "sheet_laplace", "model_id": 6}
|
|
# sheet 벌을 미리 떠 둠: 노선 하나(길이 777)
|
|
asyncio.run(sets.park_active_set(conn, tmp_path, PID, FP))
|
|
db.execute(
|
|
"INSERT INTO routes (project_id, surface_model_id, status, total_length_m) VALUES"
|
|
" (?, 6, 'CONFIRMED', 777)",
|
|
(PID,),
|
|
)
|
|
db.commit()
|
|
(tmp_path / "B06_Section").mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / "B06_Section" / "sheet.json").write_text("sheet", encoding="utf-8")
|
|
asyncio.run(sets.park_active_set(conn, tmp_path, PID, sheet_fp))
|
|
asyncio.run(sets.load_set(conn, tmp_path, PID, "las"))
|
|
state = {"design": "las"}
|
|
|
|
async def params(_c, _pid):
|
|
return {
|
|
"source_filter": "sheet_laplace" if state["design"] == "sheet" else "classification"
|
|
}
|
|
|
|
async def fingerprint(_c, _pid, _root, terrain):
|
|
return sheet if terrain == "sheet" else FP
|
|
|
|
async def switch(_c, _pid, terrain):
|
|
state["design"] = terrain
|
|
db.execute(
|
|
"UPDATE surface_models SET status = CASE id WHEN 6 THEN 'CONFIRMED' ELSE 'COMPLETE' END"
|
|
)
|
|
return 6
|
|
|
|
monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool(conn))
|
|
monkeypatch.setattr(sets, "get_surface_confirmation_params", params)
|
|
monkeypatch.setattr(sets, "set_fingerprint", fingerprint)
|
|
monkeypatch.setattr(sets, "switch_terrain", switch)
|
|
monkeypatch.setattr(chain_mod, "run_auto_design_chain", chain)
|
|
return db, state
|
|
|
|
|
|
def test_맞바꿈_묶음_있으면_꺼냄(monkeypatch, tmp_path: Path) -> None:
|
|
called = []
|
|
|
|
async def chain(*a, **k):
|
|
called.append(a)
|
|
|
|
sheet_fp = {**FP, "terrain": "sheet", "source_filter": "sheet_laplace", "model_id": 6}
|
|
db, _ = _swap_world(monkeypatch, tmp_path, sheet_fp=sheet_fp, chain=chain)
|
|
result = asyncio.run(sets.swap_design_set(PID, tmp_path, "sheet")) # type: ignore[arg-type]
|
|
assert result["changed"] and not result["built"] and not called
|
|
assert _rows(db, "SELECT total_length_m FROM routes") == [(777.0,)]
|
|
assert (tmp_path / "B06_Section" / "sheet.json").read_text(encoding="utf-8") == "sheet"
|
|
assert sets.bundle_ready(tmp_path, "las", FP) # 떠난 벌 살림
|
|
assert not (tmp_path / SETS_DIR / "sheet").exists()
|
|
|
|
|
|
def test_맞바꿈_묵은_벌은_버리고_지음(monkeypatch, tmp_path: Path) -> None:
|
|
called = []
|
|
holder: dict = {}
|
|
|
|
async def chain(pid, model_id, inner=False):
|
|
called.append((model_id, inner))
|
|
holder["db"].execute(
|
|
"INSERT INTO routes (project_id, surface_model_id, status, total_length_m) VALUES"
|
|
" (?, 6, 'CONFIRMED', 555)",
|
|
(PID,),
|
|
)
|
|
return {"route_id": 1}
|
|
|
|
stale = {**FP, "terrain": "sheet", "source_filter": "sheet_laplace", "model_digest": "old"}
|
|
db, _ = _swap_world(monkeypatch, tmp_path, sheet_fp=stale, chain=chain)
|
|
holder["db"] = db
|
|
result = asyncio.run(sets.swap_design_set(PID, tmp_path, "sheet")) # type: ignore[arg-type]
|
|
assert result["built"] and called == [(6, True)]
|
|
assert _rows(db, "SELECT total_length_m FROM routes") == [(555.0,)] # 묵은 777 버림
|
|
assert not (tmp_path / "B06_Section" / "sheet.json").exists()
|
|
assert sets.bundle_ready(tmp_path, "las", FP)
|
|
|
|
|
|
def test_맞바꿈_실패면_통째_되돌림(monkeypatch, tmp_path: Path) -> None:
|
|
async def chain(*_a, **_k):
|
|
return None # 지음 실패
|
|
|
|
stale = {**FP, "terrain": "sheet", "source_filter": "sheet_laplace", "model_digest": "old"}
|
|
db, _ = _swap_world(monkeypatch, tmp_path, sheet_fp=stale, chain=chain)
|
|
before_db, before_files = _no_ids(db), _tree(tmp_path)
|
|
route_ids = _rows(db, "SELECT id FROM routes ORDER BY id")
|
|
sheet_meta = sets.read_meta(tmp_path, "sheet")
|
|
models = _rows(db, "SELECT id, status FROM surface_models ORDER BY id")
|
|
with pytest.raises(RuntimeError):
|
|
asyncio.run(sets.swap_design_set(PID, tmp_path, "sheet")) # type: ignore[arg-type]
|
|
assert _no_ids(db) == before_db
|
|
assert _rows(db, "SELECT id FROM routes ORDER BY id") == route_ids
|
|
assert _rows(db, "SELECT id, status FROM surface_models ORDER BY id") == models
|
|
assert _tree(tmp_path) == before_files
|
|
assert sets.read_meta(tmp_path, "sheet") == sheet_meta # 묶음도 그대로
|
|
assert not (tmp_path / SETS_DIR / "las").exists()
|