"""구조물 집계표 항목 지정 · 저장 스냅샷 · 채움 (PLAN 21-3 · 임시 저장소). - `summary_item` 칸(구조물 · 관 지점) 모양 검사 · 옛 파일(칸 없음) 호환 - 저장 뒤 스냅샷: 지정 → 폴더 생김 · 원본(마스터) 바꿔도 프로젝트 값 그대로 · 지정 바꿈 → 옛 폴더 지움 · 해제 → 지움 · 회사 층 자리 - 채움(`fill_table`) = 지정 항목 모드 — 고른 열에만 · 미지정은 안 셈 · 스냅샷 열 정의 사용 - [초기화]는 `templates/` 를 안 건드림 · 프로젝트 복제 도구는 폴더를 통째 복사 """ from __future__ import annotations import json import shutil from pathlib import Path from typing import Any import pytest from B05_Profile.B05_Profile_Structures_Schema import StructureInstance from common_util import common_util_initial_snapshot as initial from common_util.common_util_drainage_pipes import PipePoint, parse_pipe_points from common_util.common_util_summary_item import parse_summary_item from config import config_system from M02_MasterTemplete import M02_Item_Snapshot as snap from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers REAL = config_system.PROJECT_ROOT / "resources/master_template" MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"} RV = {"layer": "system", "owner": None, "key": "SS000001", "name": "돌기슭막이 메쌓기 1.2m"} PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"} def _copy_layer(target: Path) -> None: target.mkdir(parents=True) for name in (layers.COMBO_FILE, "자재조합.json"): shutil.copy2(REAL / name, target / name) for kind in ("table", "structure", "area", "material"): shutil.copytree(REAL / kind, target / kind) @pytest.fixture def world(tmp_path: Path, monkeypatch) -> dict[str, Any]: system = tmp_path / "master" _copy_layer(system) monkeypatch.setattr(layers, "SYSTEM_ROOT", system) storage = tmp_path / "storage" monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) root = storage / "3" / "12" / "proj" (root / "B05_Profile/route").mkdir(parents=True) (root / "B04_PreProcess/drainage/edits").mkdir(parents=True) return {"system": system, "storage": storage, "root": root} def _save(root: Path, structures: list[dict], points: list[dict]) -> dict[str, list[str]]: """화면 [저장] 두 곳(구조물 · 관 지점)이 쓴 뒤 서버가 맞추는 것과 같게.""" for index, item in enumerate(structures): item.setdefault("structure_id", f"st{index}") item.setdefault("placement", "interval" if "start_m" in item else "point") (root / "B05_Profile/route/structures.json").write_text( json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False), encoding="utf-8" ) (root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text( json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" ) return snap.sync_snapshots(root) def _dry(summary: dict | None) -> dict[str, Any]: wall = {"type_id": "masonry_dry", "chainage_m": 85, "start_m": 80, "end_m": 90} return {**wall, "options": {"height_m": 2.0, "length_m": 10}, "summary_item": summary} def _wet() -> dict[str, Any]: wall = {"type_id": "masonry_wet", "chainage_m": 120, "start_m": 120, "end_m": 130} return {**wall, "options": {"height_m": 2.5, "length_m": 10}} def _pipe(summary: dict | None) -> dict[str, Any]: options = {"pipe_kind": "파형강관", "pipe_diameter_mm": "1000"} return {"chainage_m": 40.0, "source": "user", "options": options, "summary_item": summary} def _items(root: Path) -> Path: return root / "templates" / "items" # ── 칸 모양 ────────────────────────────────────────── @pytest.mark.parametrize( "bad", [ {"layer": "project", "owner": None, "key": "SS000001", "name": "x"}, {"layer": "system", "owner": "3", "key": "SS000001", "name": "x"}, {"layer": "company", "owner": "3/12", "key": "SS000001", "name": "x"}, {"layer": "personal", "owner": "3", "key": "SS000001", "name": "x"}, {"layer": "system", "owner": None, "key": "UA000001", "name": "x"}, {"layer": "system", "owner": None, "key": "SS000001", "name": ""}, {"layer": "system", "owner": None, "key": "SS000001", "name": "x", "more": 1}, "SS000001", ], ) def test_모양이_틀린_항목은_거절(bad): with pytest.raises(ValueError): parse_summary_item(bad) with pytest.raises(ValueError): StructureInstance(type_id="x", placement="point", chainage_m=1, summary_item=bad) with pytest.raises(ValueError): parse_pipe_points([{"chainage_m": 1.0, "summary_item": bad}]) def test_옛_파일은_칸_없음_그대로_읽고_씀(): item = StructureInstance(type_id="x", placement="point", chainage_m=1) assert item.summary_item is None point = parse_pipe_points([{"chainage_m": 10.0, "source": "user"}])[0] assert point.summary_item is None and "summary_item" not in point.as_dict() owned = {"layer": "personal", "owner": "3/12", "key": "SS000002", "name": "내 옹벽"} again = parse_pipe_points([PipePoint(chainage_m=5.0, summary_item=owned).as_dict()])[0] assert again.summary_item == owned # ── 스냅샷 ─────────────────────────────────────────── def test_지정하면_폴더가_생기고_내용이_원본과_같음(world): root = world["root"] result = _save(root, [_dry(MS), _wet()], [_pipe(PIPE)]) assert result == { "만듦": ["system_none_SS000011", "system_none_SS000022"], "지움": [], "못만듦": [], } folder = _items(root) / "system_none_SS000022" got = {path.name for path in folder.iterdir()} assert {"집계.json", "도면.json", "면적.json", "재료.json", "manifest.json"} <= got total = json.loads((folder / "집계.json").read_text(encoding="utf-8")) assert total["줄"]["키"] == "SS000022" and total["열"]["id"] == "ms" drawing = json.loads((world["system"] / "structure/ms.json").read_text(encoding="utf-8")) assert json.loads((folder / "도면.json").read_text(encoding="utf-8")) == drawing manifest = json.loads((folder / "manifest.json").read_text(encoding="utf-8")) assert manifest["원본"] == MS and manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16 pipe = json.loads( (_items(root) / "system_none_SS000011/manifest.json").read_text(encoding="utf-8") ) assert pipe["구조물"] == ["pipe@40.0"] # 일위대가 UA 줄 + 담은 로직 줄(M01 시스템) labor = json.loads( (_items(root) / "system_none_SS000011/일위대가.json").read_text(encoding="utf-8") ) assert labor["줄"]["키"] == "UA000036" assert [row["키"] for row in labor["로직"]] + labor["빠진로직"] == [ entry["로직"] for entry in labor["줄"]["담은로직"] ] def test_원본을_바꿔도_프로젝트_값은_그대로(world): root, system = world["root"], world["system"] _save(root, [_dry(MS)], []) before = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8") table = json.loads((system / "table/구조물집계표.json").read_text(encoding="utf-8")) for column in table["열"]: if column["id"] == "ms": column["바인딩"]["머리틀"] = ["바뀐 돌쌓기", "{0}", "{1}"] (system / "table/구조물집계표.json").write_text( json.dumps(table, ensure_ascii=False), encoding="utf-8" ) assert _save(root, [_dry(MS)], [])["만듦"] == [] # 다시 저장해도 안 다시 씀 after = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8") assert after == before doc = fill.fill_table(root, table) heads = [c["머리"][0] for c in doc["열"] if c["id"].startswith("ms|")] assert heads == ["돌쌓기"] # 채움도 스냅샷 열 정의 def test_지정_바꿈_해제_삭제면_옛_폴더를_지움(world): root = world["root"] _save(root, [_dry(MS), _dry(MS)], []) manifest = _items(root) / "system_none_SS000022/manifest.json" assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st0", "st1"] # 한 구조물만 바꿈 — 같은 항목을 쓰는 구조물이 남으면 폴더 유지 · 목록만 줄어듦 result = _save(root, [_dry(RV), _dry(MS)], []) assert result["만듦"] == ["system_none_SS000001"] and result["지움"] == [] assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st1"] # 해제 · 삭제 assert _save(root, [_dry(None)], [])["지움"] == ["system_none_SS000001", "system_none_SS000022"] assert list(_items(root).iterdir()) == [] def test_회사_층_항목은_회사_폴더에서_복사(world): root = world["root"] company = world["storage"] / "3" / "templates" _copy_layer(company) combo = json.loads((company / layers.COMBO_FILE).read_text(encoding="utf-8")) for row in combo["줄"]: if row["키"] == "SS000022": row["분류3"] = "회사 것" (company / layers.COMBO_FILE).write_text(json.dumps(combo, ensure_ascii=False), "utf-8") owned = {**MS, "layer": "company", "owner": "3"} assert _save(root, [_dry(owned)], [])["만듦"] == ["company_3_SS000022"] total = json.loads((_items(root) / "company_3_SS000022/집계.json").read_text("utf-8")) assert total["줄"]["분류3"] == "회사 것" # 채움 — 회사 항목은 시스템 열과 안 겹치는 새 열 doc = fill.fill_table( root, json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8")) ) ids = [c["id"] for c in doc["열"] if "@" in c["id"]] assert ids == ["ms@company_3_SS000022|메쌓기|2.0"] def test_원본에_없는_항목은_못만듦으로_남김(world): gone = {**MS, "key": "SS999999"} assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["system_none_SS999999"] # ── 채움 ───────────────────────────────────────────── def test_채움은_지정한_열에만_미지정은_안_셈(world): root = world["root"] _save(root, [_dry(MS), _wet()], [_pipe(PIPE), {**_pipe(None), "chainage_m": 60.0}]) table = json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8")) doc = fill.fill_table(root, table, {40.0: 12.0, 60.0: 9.0}) values = {row["id"]: row["값"] for row in doc["줄"] if not row.get("고정")} assert set(values) == {"s80.00~90.00", "s40.00"} # 미지정 옹벽 · 관은 줄도 없음 assert values["s80.00~90.00"]["ms|메쌓기|2.0"] == 10 # 메쌓기는 더함 출처(종류)로 assert values["s40.00"]["pp_len|파형강관|1000"] == 12 assert not any("찰쌓기" in c["id"] for c in doc["열"]) # 자동 맞춤 없음 assert "집계 항목 미지정 2건 — 표에 안 들어감" in doc["알림"] def test_스냅샷이_없으면_알림(world): root = world["root"] _save(root, [_dry(MS)], []) shutil.rmtree(_items(root)) doc = fill.fill_table( root, json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8")) ) assert "집계 항목 복사본 없음 1건 — [저장] 뒤 채워짐" in doc["알림"] # ── 초기화 · 복제 ──────────────────────────────────── def test_초기화는_templates_를_안_건드리고_복제는_통째로(): assert not any(tree.startswith("templates") for tree in initial._FILE_TREES) tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8") assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates/items 포함)