Files
Aislo/resources/tester/test_m02_tree.py
T
eomsangdonandClaude Opus 5.5 b5114ec17f feat(M02): 집계 항목 폴더 구조 서버 — 경로 · 일련번호 · 나무 고침 길(PLAN 26-2a)
- 항목 경로 = 폴더 이름 배열(깊이 자유) · 집계표 열 머리(빈 칸 건너뜀)가 곧 경로 · 열에 경로를 같이 적음 · 머리 줄 수(층)는 가장 깊은 경로에 맞춤(넷 밑으로는 안 줄임) · 빈 폴더는 집계표 폴더 칸(M02_MasterTemplete_Tree)
- 표 저장과 나무 고침 한 줄기 — 나무 고침은 집계표 문서를 고쳐 같은 집계표 저장(store.write)을 탐(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기가 함께)
- 나무 고침 길 POST /api/m02/layers/{layer}/tree(add-folder · add-item · rename · move · delete · 판 409 · 422 · 404 · 층 권한) · 읽기 GET 같은 길 {판, 폴더, items}
- 번호 = 새 항목은 자리 뜻 없는 일련번호(있던 번호 · 지운 번호 가장 큰 수 + 1) · 있던 번호 그대로
- SS 장부 = M02_MasterTemplete_Book 로 떼어 냄(Store 543 → 478) · 줄에 경로 · 이름 · 옛 종류 · 분류1~3 = 경로 앞 넷 · 경로 없는 옛 줄은 읽을 때 채움 · 옮기는 함수 migrate_paths
- 목록 길 /api/m02/items 줄에 경로 · 이름(경로 이음) · 자재 조합 · 갈라 내기 이름도 경로 이음
- 마스터 집계표 · 장부를 경로 모양으로 옮김(51 항목 · 키 · 번호 그대로 · 빈 글 머리는 null)
- 시험 test_m02_tree 5(옛 51 항목 · 옮기기 두 번 같음 · 깊이 1 · 2 · 5 · 이름 · 옮기기 · 지우기 · 마지막 항목 · 409 · 403 · 표 저장 한 줄기) · 번호 시험을 일련번호 규칙으로

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

248 lines
13 KiB
Python

"""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()