"""M02 집계 항목 폴더 구조(PLAN 26-1 · 26-2a) — 경로 · 옛 자료 읽기 · 일련번호 · 나무 고침 · 권한. ① 옛 마스터(실제 `resources/master_template` · 읽기만) 51 항목이 경로로 읽힘 — 개수 · 이름(옛 종류 · 분류1~3 이음과 같음) · 키 차례. ② 나무 고침은 임시 storage(회사 · 개인 층)와 견본 시스템 집계표로만 — 빈 층 첫 폴더 · 항목 · 깊이 1 · 2 · 5 · 이름 바꾸기 · 옮기기 · 지우기(세 파일 · 입력 연결 같이) · 마지막 항목 · 판 409 · 422 · 404 · 권한 403 · 표 저장과 한 줄기(머리를 고치면 경로가 따라옴) · 번호 일련 · 안 되돌림. """ from __future__ import annotations import json from pathlib import Path from typing import Any import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from common_util.common_util_auth import verify_session from config import config_system from M02_MasterTemplete import M02_MasterTemplete_Book as book from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree from M02_MasterTemplete import M02_Template_Layers as layers from resources.tester.test_m02_structure import write_sample_table TABLE = "table/구조물집계표" MASTER = Path(__file__).resolve().parents[2] / "resources" / "master_template" def _app(session: dict[str, Any]) -> TestClient: app = FastAPI() app.include_router(layers_router.router) app.include_router(items_router.router) app.dependency_overrides[verify_session] = lambda: session return TestClient(app) # ── ① 옛 마스터 — 읽기만 ───────────────────────────── def test_옛_마스터_51_항목이_경로로_읽힘(): raw = json.loads((MASTER / store.COMBO_FILE).read_bytes())["줄"] old = [ " ".join(str(r[k]) for k in ("종류", "분류1", "분류2", "분류3") if r.get(k)) for r in raw ] client = _app({"user_id": 1, "company_id": 1, "role": "USER", "is_master": False}) rows = client.get("/api/m02/items").json() assert len(rows) == len(raw) == 51 assert [r["key"] for r in rows] == [r["키"] for r in raw] assert [r["이름"] for r in rows] == old assert all(r["경로"] and r["이름"] == " ".join(r["경로"]) for r in rows) got = client.get("/api/m02/layers/system/tree").json() assert len(got["items"]) == 51 and got["판"] assert ["돌기슭막이"] in got["폴더"] and ["돌기슭막이", "메쌓기"] in got["폴더"] # 옛 줄(경로 없음)도 종류 · 분류1~3 에서 경로로(빈 분류 건너뜀) assert tree.row_path({"종류": "대피소", "분류1": None, "분류2": "", "분류3": "x"}) == [ "대피소", "x", ] def test_옮기기_함수는_두_번_돌려도_같음(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): for kind in store.KINDS: (tmp_path / kind).mkdir() for name in ("table/구조물집계표.json", store.COMBO_FILE): (tmp_path / name).write_bytes((MASTER / name).read_bytes()) monkeypatch.setattr(store, "FOLDER", tmp_path) before = json.loads((tmp_path / store.COMBO_FILE).read_bytes())["줄"] store.backfill_missing_structures() # 사본에 세 파일(번호) — 옮기기가 번호를 안 바꾸는지 봄 assert book.migrate_paths() is True assert book.migrate_paths() is False after = json.loads((tmp_path / store.COMBO_FILE).read_bytes())["줄"] assert [r["키"] for r in after] == [r["키"] for r in before] assert all(r["경로"] == tree.row_path({**r, "경로": None}) for r in after) table = json.loads((tmp_path / "table/구조물집계표.json").read_bytes()) assert table["층"] == ["종류", "분류1", "분류2", "분류3"] and table["폴더"] == [] assert all(c["경로"] == tree.path_of(c) for c in table["열"] if tree.is_item(c)) # ── ② 나무 고침 — 임시 storage ────────────────────────── @pytest.fixture() def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: system = tmp_path / "system" system.mkdir() write_sample_table(system) monkeypatch.setattr(store, "FOLDER", system) monkeypatch.setattr(layers, "SYSTEM_ROOT", system) store.renumber() storage = tmp_path / "storage" storage.mkdir() monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} return {"client": _app(session), "session": session, "storage": storage, "system": system} def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: world["session"].update(role=role, user_id=user) return world["client"] class Tree: """한 층 나무를 판을 따라가며 고침.""" def __init__(self, client: TestClient, layer: str): self.client, self.layer = client, layer self.url = f"/api/m02/layers/{layer}/tree" self.now = client.get(self.url).json() def do(self, want: int = 200, **body: Any) -> dict[str, Any]: res = self.client.post(self.url, json={"판": self.now["판"], **body}) assert res.status_code == want, res.text if want == 200: self.now = res.json() assert self.now == {**self.client.get(self.url).json(), "알림": self.now["알림"]} return res.json() def item(self, path: list[str]) -> dict[str, Any]: return next(r for r in self.now["items"] if r["경로"] == path) def paths(self) -> list[list[str]]: return [r["경로"] for r in self.now["items"]] def _numbers(root: Path, cid: str) -> set[str]: out = set() for kind, key, _, _ in store.PARTS: doc = json.loads((root / kind / f"{cid}.json").read_bytes()) out.add(doc[key][2:]) return out def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(world): t = Tree(_as(world), "personal") root = world["storage"] / "7" / "42" / "templates" assert (t.now["폴더"], t.now["items"]) == ([], []) # 빈 층 t.do(op="add-folder", 경로=[], 이름="돌쌓기") assert t.now["폴더"] == [["돌쌓기"]] and t.now["items"] == [] t.do(op="add-item", 경로=[], 이름="소단", 단위="m") # 깊이 1 t.do(op="add-item", 경로=["돌쌓기"], 이름="찰쌓기") # 깊이 2 t.do(op="add-item", 경로=["a", "b", "c", "d"], 이름="e") # 깊이 5(폴더는 저절로) assert t.paths() == [["소단"], ["돌쌓기", "찰쌓기"], ["a", "b", "c", "d", "e"]] deep = t.item(["a", "b", "c", "d", "e"]) assert deep["이름"] == "a b c d e" assert (deep["종류"], deep["분류1"], deep["분류2"], deep["분류3"]) == ("a", "b", "c", "d") assert t.item(["소단"])["단위"] == "m" table = json.loads((root / "table/구조물집계표.json").read_bytes()) assert table["층"] == ["종류", "분류1", "분류2", "분류3", "분류4"] # 가장 깊은 경로에 맞춤 assert all(len(c["머리"]) == 5 for c in table["열"]) # 번호 = 자리 뜻 없는 일련번호 · 세 파일 같은 번호 codes = [_numbers(root, t.item(p)["열"]) for p in t.paths()] assert codes == [{"00000001"}, {"00000002"}, {"00000003"}] assert ["a", "b", "c", "d"] in t.now["폴더"] and ["돌쌓기"] in t.now["폴더"] # 겹침 409 · 모양 422 · 없는 것 404 t.do(409, op="add-item", 경로=["돌쌓기"], 이름="찰쌓기") t.do(409, op="add-folder", 경로=[], 이름="a") for bad in ( {"op": "grow"}, {"op": "add-item", "이름": " "}, {"op": "add-item", "이름": "x" * 61}, ): t.do(422, **bad) t.do(404, op="rename", 키="SS999999", 이름="x") t.do(404, op="delete", 경로=["없는폴더"]) # 이름 바꾸기 — 항목(키 · 열 · 번호 그대로) · 폴더(아래 항목 모두) key = t.item(["돌쌓기", "찰쌓기"])["key"] t.do(op="rename", 키=key, 이름="메쌓기") moved = t.item(["돌쌓기", "메쌓기"]) assert moved["key"] == key and _numbers(root, moved["열"]) == {"00000002"} t.do(op="rename", 경로=["a"], 이름="A") assert ["A", "b", "c", "d", "e"] in t.paths() and ["a"] not in t.now["폴더"] t.do(409, op="rename", 경로=["A"], 이름="돌쌓기") # 있는 폴더로 합치기 막음 # 옮기기 — 항목 · 폴더 · 제 안으로는 422 t.do(op="move", 키=t.item(["소단"])["key"], 새경로=["돌쌓기"]) t.do(op="move", 경로=["A", "b"], 새경로=["돌쌓기"]) assert t.paths() == [["돌쌓기", "메쌓기"], ["돌쌓기", "소단"], ["돌쌓기", "b", "c", "d", "e"]] t.do(422, op="move", 경로=["돌쌓기"], 새경로=["돌쌓기", "b"]) # 지우기 — 항목의 세 파일 · 입력 연결 파일도 같이 · 마지막 항목까지 gone = t.item(["돌쌓기", "소단"]) (root / "inputs").mkdir(exist_ok=True) (root / "inputs" / f"{gone['열']}.json").write_text('{"판": 1, "줄": []}', encoding="utf-8") note = t.do(op="delete", 키=gone["key"])["알림"] assert note["지운항목"] == [gone["열"]] for part in ("structure", "area", "material", "inputs"): assert not (root / part / f"{gone['열']}.json").is_file() t.do(op="add-item", 경로=["돌쌓기"], 이름="새것") # 지운 번호는 다시 안 줌 assert _numbers(root, t.item(["돌쌓기", "새것"])["열"]) == {"00000004"} t.do(op="delete", 경로=["돌쌓기", "b"]) # 깊은 항목이 빠지면 머리 줄 넷으로 table = json.loads((root / "table/구조물집계표.json").read_bytes()) assert table["층"] == ["종류", "분류1", "분류2", "분류3"] t.do(op="delete", 경로=["돌쌓기"]) # 마지막 항목들까지 assert t.now["items"] == [] and t.now["폴더"] == [] assert json.loads((root / "table/구조물집계표.json").read_bytes())["열"] == [] def test_판_409_권한_403(world): admin = Tree(_as(world, "ADMIN"), "company") stale = admin.now["판"] admin.do(op="add-folder", 경로=[], 이름="회사폴더") res = admin.client.post(admin.url, json={"판": stale, "op": "add-folder", "이름": "x"}) assert res.status_code == 409 and res.json()["detail"]["판"] == admin.now["판"] user = _as(world) # 같은 회사 일반 사용자 — 읽기 200 · 고침 403 assert user.get("/api/m02/layers/company/tree").json()["폴더"] == [["회사폴더"]] body = {"판": admin.now["판"], "op": "add-folder", "이름": "y"} assert user.post("/api/m02/layers/company/tree", json=body).status_code == 403 assert user.get("/api/m02/layers/personal/tree?owner=7/9").status_code == 403 # 남의 개인 system = user.get("/api/m02/layers/system/tree").json() body = {"판": system["판"], "op": "add-folder", "이름": "y"} assert user.post("/api/m02/layers/system/tree", json=body).status_code == 403 sysadmin = Tree(_as(world, "SYSTEM_ADMIN"), "system") sysadmin.do(op="add-item", 경로=["돌붙임"], 이름="줄붙임") assert ["돌붙임", "줄붙임"] in sysadmin.paths() def test_표_저장과_나무가_한_줄기(world): """집계표 표에서 머리를 고치면 경로가 따라오고, 나무 고침 뒤 표에도 같은 자료가 보임.""" admin = _as(world, "SYSTEM_ADMIN") t = Tree(admin, "system") rv = next(r for r in t.now["items"] if r["열"] == "rv") url = f"/api/m02/layers/system/templates/{TABLE}" got = admin.get(url).json() col = next(c for c in got["문서"]["열"] if c["id"] == "rv") col["머리"] = ["돌기슭막이", None, "찰쌓기", None] # 빈 칸은 건너뜀 assert admin.put(url, json=got).status_code == 200 t = Tree(admin, "system") assert t.item(["돌기슭막이", "찰쌓기"])["key"] == rv["key"] t.do(op="rename", 키=rv["key"], 이름="메쌓기") table = admin.get(url).json()["문서"] col = next(c for c in table["열"] if c["id"] == "rv") assert col["경로"] == ["돌기슭막이", "메쌓기"] and col["머리"] == [ "돌기슭막이", "메쌓기", None, None, ] # 표에서 열을 지우면 나무에서도 빠지고 파일도 같이 table["열"] = [c for c in table["열"] if c["id"] != "rv"] assert ( admin.put(url, json={"판": admin.get(url).json()["판"], "문서": table}).status_code == 200 ) assert "rv" not in [r["열"] for r in Tree(admin, "system").now["items"]] assert not (world["system"] / "structure" / "rv.json").is_file() def test_프로젝트에서_새_항목도_경로_그대로(world): """개인에 저장 · 프로젝트에서 새 항목(`M02_Item_Personal`)도 경로를 넷에 접지 않고 그대로 · 같은 경로면 「이름 (2)」.""" from M02_MasterTemplete import M02_Item_Personal as personal folder = layers.personal_dir(7, 42) made = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m") again = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m") t = Tree(_as(world), "personal") assert t.item(["가", "나", "다", "라", "마"])["key"] == made["key"] assert t.item(["가", "나", "다", "라", "마 (2)"])["key"] == again["key"] assert made["이름"] == "가 나 다 라 마"