Files
Aislo/resources/tester/test_structures_summary_snapshot.py
T
eomsangdonandClaude Opus 5.5 fbb214cd94 feat(M02): 집계 항목 복사본을 구조물마다 — templates/structures/{구조물 id} · 옛 항목 폴더 옮겨 받기 · 재적용(덮임 알림) · 개인에 저장 · 프로젝트에서 새 항목 · 지정 바꿈 · 삭제 때 고친 것은 _지난 (PLAN 26-2b)
- 같은 항목을 고른 구조물 둘도 도면 · 면적 · 재료 · 자재조합 · 일위대가 · 입력연결 · 입력값 · 고른자재 · 단가를 따로 가짐 · 폴더 = structure_id 또는 pipe@측점
- 옛 templates/items/{층}_{주인}_{키} 는 읽을 때 그 항목을 쓰는 구조물마다 복사(고친 값 유지) · 안 쓰이고 고친 것은 structures/_지난
- 길: 미리 가져오기 {structure, layer, owner, key} · POST …/{폴더}/reapply(refresh 옛 이름) · …/save-as {to, 경로, 이름} · …/items/new {structure, 경로, 이름, 단위} · 회사 없음 409
- manifest 원본에 경로(26-1 · 경로 칸 우선 · 옛 종류 · 분류는 건너뛰며 받음) · 원본이 바뀌거나 지워져도 복사본 · 계산 그대로(목록 마스터바뀜 · 원본없음)
- 채움(M02_Table_Fill)은 구조물 복사본의 열 정의를 읽음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-30 07:45:20 +09:00

247 lines
12 KiB
Python

"""구조물 집계표 항목 지정 · 저장 스냅샷 · 채움 (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:
"""구조물 복사본 뿌리(26-2b) — 구조물마다 폴더(`structure_id` · 관은 `pipe@측점`)."""
return root / "templates" / "structures"
# ── 칸 모양 ──────────────────────────────────────────
@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 == {"만듦": ["pipe@40.0", "st0"], "지움": [], "못만듦": [], "옮겨받음": []}
folder = _items(root) / "st0"
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, "경로": ["돌쌓기", "(찰·메마다)", "H=(높이마다)"]}
assert manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16
pipe = json.loads((_items(root) / "pipe@40.0/manifest.json").read_text(encoding="utf-8"))
assert pipe["구조물"] == ["pipe@40.0"]
# 일위대가 UA 줄 + 담은 로직 줄(M01 시스템)
labor = json.loads((_items(root) / "pipe@40.0/일위대가.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) / "st0/집계.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) / "st0/집계.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)], [])
assert sorted(p.name for p in _items(root).iterdir()) == ["st0", "st1"]
result = _save(root, [_dry(RV), _dry(MS)], [])
assert result["만듦"] == ["st0"] and result["지움"] == ["st0"] # 안 고쳤으니 옛 것은 지움
assert json.loads((_items(root) / "st0/manifest.json").read_text("utf-8"))["원본"]["key"] == (
"SS000001"
)
assert json.loads((_items(root) / "st1/manifest.json").read_text("utf-8"))["원본"]["key"] == (
"SS000022"
)
# 해제 · 삭제
assert _save(root, [_dry(None)], [])["지움"] == ["st0", "st1"]
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)], [])["만듦"] == ["st0"]
total = json.loads((_items(root) / "st0/집계.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)], [])["못만듦"] == ["st0"]
# ── 채움 ─────────────────────────────────────────────
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 포함)