Files
Aislo/resources/tester/test_573_1_clone_de.py
T
eomsangdonandClaude Opus 5.5 6f95797250 feat(복제 도구): D(예정노선) · E(안정성평가) 프로젝트 통째 복제 · 종류 자동 판별 · --dry-run (PLAN 573-1 · 요구 404 · 405)
- D 표 넷(plan_networks · plan_route_pieces · plan_route_years · plan_route_assignees) 복제 · 업체 지정 id 새로 · 이은 임도 끊음
- E 원본 칸 · 단계 행 · 파일은 projects 행째 · 폴더째
- 종류(project_kind → B · D · E) 판별 · 목록 밖 project_id 표 경고 · manifest 에 종류 · 빠진 표
- --dry-run = 종류 · 표마다 행 수 · 폴더 크기(쓰지 않음)
- 새 시험 test_573_1_clone_de — 임시 storage · 끝에 hard_delete_project

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:25:12 +09:00

229 lines
8.7 KiB
Python

"""573-1 복제 도구 D · E 갈래 — 임시 storage 사본에서(요구 404 · 405).
- D(예정노선) — plan_networks · plan_route_pieces · plan_route_years · plan_route_assignees ·
plan_base_json · 지표면 · 지형층 행 수 같음 · 자동증가 id 새로 · 지형층 → 새 지표면 ·
업체 연결 끊김 · 지형 models_plan · _derived 파일 그대로
- E(안정성평가) — 원본 칸(source_project_id 등) 그대로 · 단계 행 여섯 상태 그대로 ·
E05 보고서 · _derived 파일 수 같음
- 둘 다 — 종류 판별 · 이름 그대로 · 파일 안 옛 UUID 없음 · 원본 행 · 파일 그대로 ·
--dry-run 은 쓰지 않음 · 만든 복제본은 끝에 hard_delete_project(임시 사본 폴더째)
"""
import asyncio
import hashlib
import importlib.util
import json
import os
import shutil
import sys
from contextlib import contextmanager
from pathlib import Path
import aiomysql
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from helper_temp_storage import _LINKED, temp_project_storage # noqa: E402
from common_util import common_util_project_copy as cp # noqa: E402
from common_util.common_util_storage import resolve_stored_project_path # noqa: E402
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER # noqa: E402
#: 가이드 예정노선 · 가이드 안정성평가(읽기만)
PLAN_ID = "87d47800-9936-4a56-9850-276fbcb34cac"
STAB_ID = "088e173d-a8ab-4c51-badb-f5a95afc43bb"
ROOT = Path(__file__).resolve().parents[2]
TABLES = (
"surface_models",
"project_members",
"project_workflow_stages",
*cp._PLAN_ALL,
)
async def _query(sql, args=()):
connection = await aiomysql.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
db=DB_NAME,
charset="utf8mb4",
autocommit=True,
)
try:
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(sql, args)
return await cursor.fetchall()
finally:
connection.close()
def _q(sql, args=()):
return asyncio.run(_query(sql, args))
def _project(project_id):
rows = _q("SELECT * FROM projects WHERE id = %s", (project_id,))
return rows[0] if rows else None
def _counts(project_id):
return {
table: _q(f"SELECT COUNT(*) AS n FROM {table} WHERE project_id = %s", (project_id,))[0]["n"]
for table in TABLES
}
def _hashes(root: Path) -> dict[str, str]:
return {
path.relative_to(root).as_posix(): hashlib.sha1(path.read_bytes()).hexdigest()
for path in root.rglob("*")
if path.is_file() and path.suffix == ".json"
}
def _files(root: Path) -> set[str]:
"""정션 자리(시험 복사가 건너뜀) 빼고."""
names = {p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()}
return {n for n in names if not n.startswith(tuple(f"{link}/" for link in _LINKED))}
def _cli():
spec = importlib.util.spec_from_file_location(
"tools_clone_project", ROOT / "db_management" / "tools_clone_project.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _copytree(source: Path, target: Path) -> None:
"""robocopy 대신 — 시험 사본의 정션(B03 · 전처리 점군)은 따라가지 않음."""
shutil.copytree(
source,
target,
ignore=lambda folder, names: [n for n in names if os.path.isjunction(Path(folder, n))],
)
async def _hard_delete(project_id, actor):
from common_util.common_util_project_delete import hard_delete_project
from config.config_db import close_db_pool, init_db_pool
await init_db_pool()
try:
return await hard_delete_project(project_id, actor)
finally:
await close_db_pool()
@contextmanager
def _clone(source_id, name, monkeypatch):
"""임시 storage 사본에서 복제 → (원본 사본 뿌리, 새 id) · 끝에 지움."""
source = _project(source_id)
if not source:
pytest.skip("원본 프로젝트가 DB 에 없음")
shared = Path(resolve_stored_project_path(source["storage_path"]))
with temp_project_storage(shared) as root:
monkeypatch.setattr(cp, "_copy_folder", _copytree)
before_rows = _counts(source_id)
before_files = _hashes(root)
new_id = None
try:
# dry-run 은 쓰지 않음
assert asyncio.run(_cli().main([source_id, "--dry-run"])) is None
assert not (root.parent / name).exists()
new_id = asyncio.run(_cli().main([source_id, name]))
yield source, root, new_id
# 원본 그대로
assert _counts(source_id) == before_rows
assert _hashes(root) == before_files
finally:
if new_id:
assert asyncio.run(_hard_delete(new_id, source["user_id"]))
assert _project(new_id) is None
assert not (root.parent / new_id).exists()
def _common(source, root, new_id):
new = _project(new_id)
assert new_id != source["id"] and new["name"].startswith("시험_573_1")
assert new["project_kind"] == source["project_kind"]
assert new["storage_path"].endswith(new_id)
assert _counts(new_id) == _counts(source["id"])
new_root = root.parent / new_id
manifest = json.loads((new_root / cp.MANIFEST_NAME).read_text(encoding="utf-8"))
assert manifest["kind"] == cp.KIND_LABELS[source["project_kind"]]
assert manifest["skipped_tables"] == {}
assert _files(new_root) - {cp.MANIFEST_NAME} == _files(root)
for path in new_root.rglob("*.json"):
if path.name != cp.MANIFEST_NAME:
assert source["id"] not in path.read_text(encoding="utf-8"), path
return new, new_root
def test_clone_plan(monkeypatch):
with _clone(PLAN_ID, "시험_573_1_D", monkeypatch) as (source, root, new_id):
new, new_root = _common(source, root, new_id)
assert json.loads(new["plan_base_json"]) == json.loads(source["plan_base_json"])
assert (new_root / "B04_PreProcess" / "models_plan").is_dir()
assert (new_root / "_derived").is_dir()
def rows(table, project_id, order):
return _q(
f"SELECT * FROM {table} WHERE project_id = %s ORDER BY {order}", (project_id,)
)
old_net, new_net = (
rows("plan_networks", PLAN_ID, "project_id"),
rows("plan_networks", new_id, "project_id"),
)
assert json.loads(new_net[0]["network_json"]) == json.loads(old_net[0]["network_json"])
for table, order in (("plan_route_pieces", "piece_id"), ("plan_route_years", "year")):
old, now = rows(table, PLAN_ID, order), rows(table, new_id, order)
drop = ("project_id", "updated_at")
assert [{k: v for k, v in r.items() if k not in drop} for r in now] == [
{k: v for k, v in r.items() if k not in drop} for r in old
]
old_a = rows("plan_route_assignees", PLAN_ID, "id")
new_a = rows("plan_route_assignees", new_id, "id")
assert not {r["id"] for r in old_a} & {r["id"] for r in new_a}
assert [(r["piece_id"], r["year"], r["company_name"]) for r in new_a] == [
(r["piece_id"], r["year"], r["company_name"]) for r in old_a
]
assert all(r["linked_project_id"] is None for r in new_a)
old_m = _q("SELECT id FROM surface_models WHERE project_id = %s", (PLAN_ID,))
new_m = _q("SELECT id FROM surface_models WHERE project_id = %s", (new_id,))
assert {r["id"] for r in new_m}.isdisjoint({r["id"] for r in old_m})
layers = _q(
"SELECT t.surface_model_id FROM terrain_layers t JOIN surface_models s"
" ON s.id = t.surface_model_id WHERE s.project_id = %s",
(new_id,),
)
assert len(layers) == 1
def test_clone_stability(monkeypatch):
with _clone(STAB_ID, "시험_573_1_E", monkeypatch) as (source, root, new_id):
new, new_root = _common(source, root, new_id)
for column in ("source_project_id", "source_copied_at", "source_fingerprint"):
assert new[column] == source[column]
def stages(project_id):
return _q(
"SELECT stage_no, stage_key, state FROM project_workflow_stages"
" WHERE project_id = %s ORDER BY stage_no",
(project_id,),
)
assert len(stages(new_id)) == 6 and stages(new_id) == stages(STAB_ID)
assert (new_root / "E05_StabReport" / "report.json").is_file()
inner = json.loads(
(new_root / cp.STABILITY_SOURCE_DIR / cp.MANIFEST_NAME).read_text(encoding="utf-8")
)
assert inner["project_id"] == new_id
assert inner["source_project_id"] == source["source_project_id"]