Files
Aislo/resources/tester/test_m02_template_layers.py
T
eomsangdonandClaude Opus 5.5 2a3051fbca feat(M02): 마스터 구조물집계표 초기화 · 시험 견본화(PLAN 26-5a)
- 마스터 층 구조물 항목 51개 지움 — 나무 지우기(tree.apply delete) → 집계표 저장(store.write) 한 줄기 · 집계표 구조물 열 · SS 장부 줄 · structure/area/material 파일 153개 · 남김 = no · sta · 비고 열 · 변수 · 쪽줄 · 합계줄 · 바인딩규칙 · 자재조합 · 구조물옵션표시 · drawing · M01 · 회사 · 개인 층 · 프로젝트 복사본 · 지운 열의 열너비만 뺌
- 키 · 번호 다시 안 씀 — 장부 다음 52(새 항목 SS000052) · 지운번호 51개 기록(새 번호는 SD-000001 모양이라 옛 8자리와 안 겹침)
- 지우기 전 사본 tmp/마스터_집계표_초기화전_0930/(git 밖 · 156 파일 = 지우기 전과 같음)
- 시험 안 견본 fixtures/m02_master_sample(옛 집계표 · 장부 · 자재조합 · rv · c4 · pp_len · ms 세 파일 · 도면 줄임) + helper_m02_master_sample — 실제 마스터를 읽던 시험 일곱(table_fill · template_layers · structures_summary_snapshot · item_copy_complete · m02_tree · m02_item_inputs · spreadsheet_saved_values)을 견본으로 · M01 쓰는 곳 찾기(users_of) 시험은 이미 임시 표(combo_inputs)라 그대로
- 새 시험 test_m02_empty_master — 빈 마스터에서 항목 0 · 빈 나무 · 첫 화면 길 · 새 폴더 · 새 항목(SS000052 · SD-000001) · 지우기 · 고르기 모달 마스터 0 · 프로젝트 복사본 calc 그대로 · 원본없음

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

401 lines
19 KiB
Python

"""M02 양식 층 — 자리 · 복사 · 초기화 · 회사 적용 · 가져오기 · 권한 (임시 storage)."""
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 import common_util_storage
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.helper_m02_master_sample import sample_table
P1 = "11111111-1111-1111-1111-111111111111"
P2 = "22222222-2222-2222-2222-222222222222"
P_OTHER = "33333333-3333-3333-3333-333333333333"
@pytest.fixture
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
storage = tmp_path / "storage"
storage.mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
monkeypatch.setattr(common_util_storage, "STORAGE_BASE_DIR", str(storage))
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path / "master_template")
layers.write_template(layers.system_dir(), "table", "구조물집계표", {"판": 1, "열": []})
layers.write_template(layers.system_dir(), "drawing", "A1_도각", {"format": 6})
projects = {
P1: {"id": P1, "name": "첫째", "company_id": 7, "user_id": 42},
P2: {"id": P2, "name": "둘째", "company_id": 7, "user_id": 43},
P_OTHER: {"id": P_OTHER, "name": "남의 회사", "company_id": 9, "user_id": 90},
}
for row in projects.values():
row["storage_path"] = f"storage/{row['company_id']}/{row['user_id']}/{row['id']}"
root = Path(common_util_storage.resolve_stored_project_path(row["storage_path"]))
layers.seed_project(root)
users = {42: 7, 43: 7, 90: 9}
async def project_row(project_id: str) -> dict[str, Any] | None:
found = projects.get(str(project_id))
return dict(found) if found else None
async def user_company(user_id: int) -> int | None:
return users.get(user_id)
async def company_users(company_id: int) -> list[dict[str, Any]]:
return [{"user_id": u, "name": f"u{u}"} for u, c in users.items() if c == company_id]
async def company_projects(company_id: int) -> list[dict[str, Any]]:
return [
{"project_id": p["id"], "name": p["name"], "storage_path": p["storage_path"]}
for p in projects.values()
if p["company_id"] == company_id
]
monkeypatch.setattr(router_module, "_project_row", project_row)
monkeypatch.setattr(router_module, "_user_company", user_company)
monkeypatch.setattr(router_module, "_company_users", company_users)
monkeypatch.setattr(router_module, "_company_projects", company_projects)
session = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: session
return {"client": TestClient(app), "session": session, "storage": storage}
def _root(world: dict[str, Any], project_id: str, company: int = 7, user: int = 42) -> Path:
return (world["storage"] / str(company) / str(user) / project_id).resolve()
def _url(project_id: str = P1) -> str:
return f"/api/m02/layers/project/templates/table/구조물집계표?project_id={project_id}"
def test_프로젝트_만들기_복사는_작업본과_초기본_둘에_manifest(world: dict[str, Any]) -> None:
root = _root(world, P1)
assert (root / "templates/table/구조물집계표.json").is_file()
assert (root / "templates/_initial/drawing/A1_도각.json").is_file()
entry = layers.read_manifest(root / "templates")["table/구조물집계표"]
assert entry["층"] == "system" and entry["판"] == layers.version_of(
layers.system_dir() / "table/구조물집계표.json"
)
def test_옛_프로젝트_넣기는_더하기만(world: dict[str, Any]) -> None:
root = _root(world, P1)
layers.write_template(root / "templates", "table", "구조물집계표", {"고침": True})
assert layers.seed_project(root, only_missing=True) == {"작업본": [], "초기": []}
assert layers.read_template(root / "templates", "table", "구조물집계표")["문서"] == {
"고침": True
}
def test_작업본_저장_판_다르면_409_초기화는_초기본으로(world: dict[str, Any]) -> None:
client = world["client"]
got = client.get(_url()).json()
assert got["층"] == "project" and got["문서"]["판"] == 1
saved = client.put(_url(), json={"판": got["판"], "문서": {"판": 2, "열": []}})
assert saved.status_code == 200
stale = client.put(_url(), json={"판": got["판"], "문서": {"판": 3, "열": []}})
assert stale.status_code == 409
reset = client.post(f"/api/m02/projects/{P1}/templates/reset", json={})
assert "table/구조물집계표" in reset.json()["초기화"]
assert client.get(_url()).json()["문서"] == {"판": 1, "열": []}
initial = _root(world, P1) / "templates/_initial/table/구조물집계표.json"
assert layers.read_template(initial.parent.parent, "table", "구조물집계표")["문서"]["판"] == 1
def test_회사_공식_저장은_관리자만_적용은_작업본만(world: dict[str, Any]) -> None:
client, session = world["client"], world["session"]
client.put(_url(), json={"판": client.get(_url()).json()["판"], "문서": {"회사": 1, "열": []}})
body = {"to": "company", "종류": "table", "이름": "구조물집계표"}
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 403
session["role"] = "ADMIN"
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200
applied = client.post(f"/api/m02/projects/{P2}/templates/apply", json={"from": "company"})
assert applied.status_code == 200
root2 = _root(world, P2, user=43)
assert layers.read_template(root2 / "templates", "table", "구조물집계표")["문서"] == {
"회사": 1,
"열": [],
}
assert (
layers.read_template(root2 / "templates/_initial", "table", "구조물집계표")["문서"]["판"]
== 1
)
assert layers.read_manifest(root2 / "templates")["table/구조물집계표"]["층"] == "company"
def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(world: dict[str, Any]) -> None:
client, session = world["client"], world["session"]
body = {"to": "personal", "종류": "table", "이름": "구조물집계표"}
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200
mine = f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}"
assert client.get(mine).status_code == 200
session.update(user_id=43)
theirs = mine + "&user_id=42"
assert client.get(theirs).status_code == 200
# 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀
put = client.put(
f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42",
json={"판": None, "문서": {"열": []}},
)
assert put.status_code == 200 # user_id 는 쓰기에서 무시 — 본인(43) 자리에 새로 씀
assert (world["storage"] / "7/43/templates/table/내표.json").is_file()
got = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
)
assert got.status_code == 200
assert client.get(mine.replace(P1, P_OTHER)).status_code == 403
far = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 90}
)
assert far.status_code == 403
def test_가져오기는_같은_회사_프로젝트만_목록도(world: dict[str, Any]) -> None:
client = world["client"]
ok = client.post(
f"/api/m02/projects/{P1}/templates/apply", json={"from": "project", "project_id": P2}
)
assert ok.status_code == 200
bad = client.post(
f"/api/m02/projects/{P1}/templates/apply", json={"from": "project", "project_id": P_OTHER}
)
assert bad.status_code == 403
sources = client.get(f"/api/m02/projects/{P1}/sources").json()
assert [row["project_id"] for row in sources["project"]] == [P2]
assert {row["이름"] for row in sources["system"]} == {"구조물집계표", "A1_도각"}
def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> None:
client = world["client"]
assert client.get("/api/m02/layers/system/templates").json()["양식"]
put = client.put(
"/api/m02/layers/system/templates/table/구조물집계표", json={"판": None, "문서": {}}
)
assert put.status_code == 403
for name in ("..", "_initial", "a.b/c"):
with pytest.raises(ValueError):
layers.template_path(layers.system_dir(), "table", name)
def test_채운_표는_작업본을_채워_돌려주고_저장_안_함(
world: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
async def designs(project_id: str) -> list[dict[str, Any]]:
return [{"chainage_m": 40.0, "design": {"pipe_length_m": 12}}]
monkeypatch.setattr(router_module, "_cross_designs", designs)
monkeypatch.setattr(router_module.fill, "recalc", lambda document: {"계산": {}})
root = _root(world, P1)
layers.write_template(root / "templates", "table", "구조물집계표", sample_table())
edits = root / "B04_PreProcess/drainage/edits"
edits.mkdir(parents=True, exist_ok=True)
(edits / "pipe_points.json").write_text(
json.dumps({"points": [{"chainage_m": 40.0, "source": "user"}]}), encoding="utf-8"
)
before = layers.version_of(root / "templates/table/구조물집계표.json")
got = world["client"].get(f"/api/m02/projects/{P1}/tables/구조물집계표/filled").json()
# 프로젝트 채움 = 지정 항목 모드(PLAN 21-3) — 집계 항목 미지정 관은 표에 안 들어감
assert not any(r["id"] == "s40.00" for r in got["문서"]["줄"])
assert "집계 항목 미지정 1건 — 표에 안 들어감" in got["문서"]["알림"]
assert got["결과"] == {"계산": {}} and got["판"] == before
assert layers.version_of(root / "templates/table/구조물집계표.json") == before
def test_프로젝트_만들기_자리에서_시스템_양식을_복사(world: dict[str, Any], tmp_path: Path) -> None:
from B02_ProjRegister.B02_ProjRegister_Repository import _initialize_project_storage
layers.write_template(layers.company_dir(7), "table", "구조물집계표", {"회사": 1})
root = tmp_path / "new_project"
_initialize_project_storage(root, "new")
got = layers.read_template(root / "templates", "table", "구조물집계표")
assert got["문서"] == {"판": 1, "열": []} # 회사 공식이 있어도 시스템
assert (root / "templates/_initial/drawing/A1_도각.json").is_file()
assert (root / "project_manifest.json").is_file()
def test_옛_프로젝트_넣기_도구는_더하기만_폴더_없으면_건너뜀(world: dict[str, Any]) -> None:
from M02_MasterTemplete import M02_Template_Migrate as migrate
old = world["storage"] / "7/42/old-project"
(old / "B05_Profile").mkdir(parents=True)
(old / "B05_Profile/keep.txt").write_text("x", encoding="utf-8")
root1 = _root(world, P1)
layers.write_template(root1 / "templates", "table", "구조물집계표", {"고침": 1})
(root1 / "templates/drawing/A1_도각.json").unlink()
projects = [
{
"id": "old",
"name": "옛",
"company_id": 7,
"user_id": 42,
"storage_path": "storage/7/42/old-project",
},
{
"id": P1,
"name": "첫째",
"company_id": 7,
"user_id": 42,
"storage_path": f"storage/7/42/{P1}",
},
{
"id": "gone",
"name": "없음",
"company_id": 7,
"user_id": 42,
"storage_path": "storage/7/42/gone",
},
]
dry = migrate.migrate(projects, dry_run=True)
assert not (old / "templates").exists() and dry[0]["상태"] == "넣을 것(시험)"
report = {row["id"]: row for row in migrate.migrate(projects)}
assert report["old"]["상태"] == "넣음" and (old / "templates/_initial/table").is_dir()
assert (old / "B05_Profile/keep.txt").read_text(encoding="utf-8") == "x"
assert report[P1]["작업본"] == ["drawing/A1_도각"]
assert layers.read_template(root1 / "templates", "table", "구조물집계표")["문서"] == {"고침": 1}
assert report["gone"]["상태"].startswith("폴더 없음")
assert not (world["storage"] / "7/42/gone").exists()
assert migrate.migrate(projects)[0]["상태"] == "이미 있음"
assert "| 7 | 42 | 옛 |" in migrate.render(list(report.values()), [])
def test_프로젝트_표_저장은_서버가_설계값을_걸러_씀(world: dict[str, Any]) -> None:
from M02_MasterTemplete import M02_Table_Fill as fill
client = world["client"]
master = sample_table()
filled = fill.fill_document(
master,
[
{
"type_id": "position_sign",
"placement": "point",
"chainage_m": 40.0,
"start_m": None,
"end_m": None,
"options": {},
}
],
)
filled["줄"][1]["값"]["h_intake"] = 2
version = client.get(_url()).json()["판"]
assert client.put(_url(), json={"판": version, "문서": filled}).status_code == 200
stored = layers.read_template(_root(world, P1) / "templates", "table", "구조물집계표")["문서"]
assert stored["줄"][1] == {"id": "s40.00", "값": {"sta": "NO.2", "h_intake": 2}}
assert "알림" not in stored and all("|" not in c["id"] for c in stored["열"])
def test_옛_표_틀은_새_판으로_손_값은_지킴(world: dict[str, Any]) -> None:
from M02_MasterTemplete import M02_Template_Migrate as migrate
new = {
"판": 2,
"열": [{"id": "a", "식": "1", "펼침틀": {"묶음": "g"}}],
"줄": [],
"변수": {"k": 1},
}
old = {
"판": 1,
"열": [{"id": "a", "식": "1", "바인딩": {"종류": "pipe", "값": "식"}}, {"id": "memo"}],
"줄": [{"id": "s10.00", "값": {"sta": "NO.0+10", "memo": "손"}}],
"변수": {"k": 5},
}
layers.write_template(layers.system_dir(), "table", "구조물집계표", new)
root2, root1 = _root(world, P2, user=43), _root(world, P1)
for folder in (root1 / "templates", root1 / "templates/_initial", root2 / "templates"):
layers.write_template(folder, "table", "구조물집계표", old)
layers.write_template(
root2 / "templates", "table", "구조물집계표", {**old, "줄": [], "변수": {"k": 1}}
)
assert migrate.refresh_tables(root1, dry_run=True) == [
"templates/table/구조물집계표",
"_initial/table/구조물집계표",
]
migrate.refresh_tables(root1)
work = layers.read_template(root1 / "templates", "table", "구조물집계표")["문서"]
assert work["열"] == new["열"] and work["변수"] == {"k": 5} # 틀만 새 판 · 변수 · 손 값 지킴
assert work["줄"] == old["줄"]
system_version = layers.version_of(layers.system_dir() / "table/구조물집계표.json")
initial = root1 / "templates/_initial"
assert layers.version_of(initial / "table/구조물집계표.json") != system_version # 변수 다름
assert layers.read_manifest(root1 / "templates")["table/구조물집계표"]["판"] == system_version
assert migrate.refresh_tables(root1) == [] # 다시 돌려도 그대로
# 손댄 것 없는 작업본은 시스템 파일 그대로(판이 같음)
migrate.refresh_tables(root2)
assert layers.version_of(root2 / "templates/table/구조물집계표.json") == system_version
def test_뼈대_없는_문서는_400으로_거절하고_파일을_안_씀(world: dict[str, Any]) -> None:
client = world["client"]
path = _root(world, P1) / "templates/drawing/A1_도각.json"
before = path.read_bytes()
url = f"/api/m02/layers/project/templates/drawing/A1_도각?project_id={P1}"
version = layers.version_of(path)
for document in ({}, {"format": 6}, {"entities": {}}):
got = client.put(url, json={"판": version, "문서": document})
assert got.status_code == 400 and "entities" in got.json()["detail"]
assert path.read_bytes() == before
table = _url()
assert client.put(table, json={"판": None, "문서": {}}).status_code == 400
assert client.put(url, json={"판": version, "문서": {"entities": []}}).status_code == 200
def _mine(name: str = "내양식", layer: str = "personal") -> str:
return f"/api/m02/layers/{layer}/templates/table/{name}?project_id={P1}"
def test_개인_회사_양식_지우기_권한_판_이름_예외(world: dict[str, Any]) -> None:
client, session = world["client"], world["session"]
doc = {"열": []}
for layer in ("personal", "company"):
client.put(_mine(layer=layer), json={"판": None, "문서": doc})
personal = layers.personal_dir(7, 42) / "table/내양식.json"
company = layers.company_dir(7) / "table/내양식.json"
assert personal.is_file() and not company.is_file() # 일반 사용자 회사 저장은 403
version = layers.version_of(personal)
# 시스템 · 프로젝트 층은 거절
for layer in ("system", "project"):
assert client.delete(_mine("구조물집계표", layer) + f"&판={version}").status_code == 403
assert (_root(world, P1) / "templates/table/구조물집계표.json").is_file()
# 이름 예외 · 판 없음 · 판 다름 · 없는 양식
for bad in ("%2E%2E", "a%2E%2Eb", "a%2Fb", "a%5Cb", "_initial", ".x", "a*b"):
got = client.delete(_mine(bad) + f"&판={version}")
assert got.status_code in (400, 404) and personal.is_file(), bad
assert client.delete(_mine("a%2Fb") + f"&판={version}").status_code in (400, 404)
assert client.delete(_mine()).status_code == 400
stale = client.delete(_mine() + "&판=0000000000000000")
assert stale.status_code == 409 and stale.json()["detail"]["판"] == version
assert client.delete(_mine("없는것") + f"&판={version}").status_code == 404
# 다른 회사 사람은 못 지움 · 회사 층은 관리자만
assert client.delete(_mine("내양식", "company") + f"&판={version}").status_code == 403
# 지움 · 연달아 누르면 둘째는 404 · manifest 줄도 없어짐
assert client.delete(_mine() + f"&판={version}").status_code == 200
assert not personal.exists()
assert client.delete(_mine() + f"&판={version}").status_code == 404
session["role"] = "ADMIN"
client.put(_mine(layer="company"), json={"판": None, "문서": doc})
assert company.is_file()
session.update(role="USER")
assert (
client.delete(_mine(layer="company") + f"&판={layers.version_of(company)}").status_code
== 403
)
session.update(role="ADMIN")
assert (
client.delete(_mine(layer="company") + f"&판={layers.version_of(company)}").status_code
== 200
)
assert "table/내양식" not in layers.read_manifest(layers.company_dir(7))