Files
Aislo/resources/tester/test_b08_structure_library.py
T

384 lines
17 KiB
Python

"""구조물도 양식 라이브러리 — 가져오기·박기 (2026-09-13, PLAN 4장 ①).
겨누는 것
① 가져오기 전은 프로그램 기본 · 머리 「가져오기 전」
② 로그인한 사람의 개인 단에서 가져오면 **프로젝트에 박히고** 원단위·자재총괄도 그 양식을 봄
③ ⛔ 박힌 뒤에는 라이브러리를 안 읽음 — 개인 파일을 지우거나 다른 사람이 열어도 값이 같음
④ 가져오면 그 종류의 고친 식이 비워짐
⑤ [확정] 때 안 박힌 기본 양식을 박음 · 코드 모양이 아니면 거절
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402
import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402
import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402
from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402
from common_util.common_util_auth import verify_session # noqa: E402
PROJECT_ID = "33333333-3333-3333-3333-333333333333"
SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets"
PERSONAL_CODE = "AX-ST-0badc0de"
@pytest.fixture()
def storage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
base = tmp_path / "storage"
monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(base))
# 개인 단(회사 7 · 사람 42)에 모르터 식을 두 배로 고친 찰쌓기 한 벌.
mine = load_template("masonry_wet")
for row in mine["rows"]:
if row["name"] == "모르터":
row["formula"] = "A*0.018"
mine.update(code=PERSONAL_CODE, library_tier="personal", name="돌쌓기(찰) 내 것")
folder = base / "7" / "42" / "library"
folder.mkdir(parents=True)
(folder / f"{PERSONAL_CODE}.json").write_text(json.dumps(mine), encoding="utf-8")
return base
@pytest.fixture()
def project(tmp_path: Path) -> Path:
root = tmp_path / "project"
root.mkdir()
wall = StructureInstance.model_validate(
{
"type_id": "masonry_wet",
"placement": "interval",
"start_m": 100.0,
"end_m": 110.0,
"options": {"height_m": 2.5, "back_len_cm": 45},
}
)
save_structures(str(root), [wall], base_revision=0)
return root
@pytest.fixture()
def client(project: Path, storage: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
async def fake_root(project_id):
return str(project)
async def no_route(project_id):
return {}
monkeypatch.setattr(router_module, "_project_root", fake_root)
monkeypatch.setattr(material_module, "_section_modes", no_route)
monkeypatch.setattr(material_module, "_ground_types", no_route)
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42}
return TestClient(app)
def _sheet(client: TestClient) -> dict:
response = client.get(SHEETS)
assert response.status_code == 200, response.text
return response.json()["sheets"][0]
def _mortar(sheet: dict) -> dict:
return next(row for row in sheet["rows"] if row["name"] == "모르터")
def test_가져오기_전은_프로그램_기본(client: TestClient) -> None:
item = _sheet(client)["library_item"]
assert item["imported_from"] is None
assert item["code"] == load_template("masonry_wet")["code"]
def test_개인_단에서_가져오면_박히고_라이브러리를_다시_안_읽는다(
client: TestClient, project: Path, storage: Path
) -> None:
before = _mortar(_sheet(client))["unit_amount"]
listed = client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()["items"]
assert [item["tier"] for item in listed] == ["personal", "program"]
taken = client.put(
f"{SHEETS}/library/import",
json={"type_id": "masonry_wet", "tier": "personal", "code": PERSONAL_CODE},
)
assert taken.status_code == 200, taken.text
sheet = _sheet(client)
assert sheet["library_item"]["imported_from"] == "personal"
assert _mortar(sheet)["unit_amount"] == pytest.approx(before * 2)
# 가져온 식은 「사용자 식」이 아니라 그 양식의 식.
assert _mortar(sheet)["source"] == "library"
# 원단위·자재총괄 창구도 박힌 양식을 봄.
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
structures, names, _ = material_module._collect_structures(str(project))
table = build_table(
structures,
names,
structure_templates=library_module.project_templates(project),
)
mortar = next(c for c in table["structures"][0]["components"] if c["name"] == "모르터")
assert mortar["amount"] == pytest.approx(_mortar(sheet)["unit_amount"] * 10.0)
# ⛔ 개인 파일이 사라지고 다른 사람이 열어도 값이 그대로.
(storage / "7" / "42" / "library" / f"{PERSONAL_CODE}.json").unlink()
client.app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 99}
assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(before * 2)
def test_가져오면_그_종류의_고친_식이_비워진다(client: TestClient, project: Path) -> None:
sheet = _sheet(client)
seq = _mortar(sheet)["no"]
client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.03"}]},
)
program_code = load_template("masonry_wet")["code"]
taken = client.put(
f"{SHEETS}/library/import",
json={"type_id": "masonry_wet", "tier": "program", "code": program_code},
)
assert taken.json()["cleared_formulas"] == 1
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
assert stored["quantity"]["structure_formula_overrides"] == {}
assert _sheet(client)["library_item"]["imported_from"] == "program"
def test_없는_항목_가져오기는_404(client: TestClient) -> None:
missing = client.put(
f"{SHEETS}/library/import",
json={"type_id": "masonry_wet", "tier": "company", "code": "AX-ST-12345678"},
)
assert missing.status_code == 404
def test_내_라이브러리에_저장은_고친_식을_담고_프로젝트는_안_바꾼다(
client: TestClient, project: Path, storage: Path
) -> None:
"""PLAN 4장 ②③ — 개인 단에 한 벌(같은 종류는 덮어씀) · 지우기도 개인 단만."""
personal = storage / "7" / "42" / "library"
sheet = _sheet(client)
seq = _mortar(sheet)["no"]
client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.03"}]},
)
settings_before = (project / "project_settings.json").read_text(encoding="utf-8")
saved = client.put(f"{SHEETS}/library/personal", json={"sheet_key": sheet["key"]})
assert saved.status_code == 200, saved.text
assert saved.json()["code"] == PERSONAL_CODE # 같은 종류가 있어 그 코드로 덮어씀
files = list(personal.glob("*.json"))
assert len(files) == 1
item = json.loads(files[0].read_text(encoding="utf-8"))
mortar_row = next(row for row in item["rows"] if row["name"] == "모르터")
assert mortar_row["formula"] == "A*0.03" and mortar_row["source"] == "library"
assert "default_formula" not in mortar_row
assert item["library_tier"] == "personal" and item["pum_edition"] == "2026-01-01"
# 프로젝트는 그대로 — 박힌 양식 없음 · 고친 식 그대로.
assert library_module.project_templates(project) == {}
assert (project / "project_settings.json").read_text(encoding="utf-8") == settings_before
# 가져온 뒤 지워도 프로젝트는 그대로.
client.put(
f"{SHEETS}/library/import",
json={"type_id": "masonry_wet", "tier": "personal", "code": PERSONAL_CODE},
)
amount = _mortar(_sheet(client))["unit_amount"]
deleted = client.delete(f"{SHEETS}/library/personal", params={"type_id": "masonry_wet"})
assert deleted.json()["deleted"] == 1 and not list(personal.glob("*.json"))
assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(amount)
def test_프로그램_기본은_storage_작업본이_씨앗을_이긴다(storage: Path) -> None:
"""관리자 UI(2026-09-14 브레인) — 프로그램 기본도 storage 작업본 · git 파일은 씨앗(없는 코드만)."""
seed = load_template("masonry_wet")
assert library_module.program_items()[0]["code"] == seed["code"] # 작업본 없으면 씨앗
edited = {**seed, "name": "돌쌓기(찰) 관리자 고침"}
folder = library_module.program_library_dir()
folder.mkdir(parents=True)
(folder / f"{seed['code']}.json").write_text(json.dumps(edited), encoding="utf-8")
items = library_module.program_items()
assert [i["name"] for i in items if i["type_id"] == "masonry_wet"] == ["돌쌓기(찰) 관리자 고침"]
assert load_template("masonry_wet")["name"] == "돌쌓기(찰) 관리자 고침"
assert str(folder).startswith(str(storage)) # 씨앗(git) 파일은 안 건드림
def test_발행은_권한대로_회사_기본_단에_같은_모양으로(client: TestClient, storage: Path) -> None:
sheet = _sheet(client)
denied = client.put(
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
)
assert denied.status_code == 403 # 시스템 관리자만
assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()[
"can_publish"
] == {"company": False, "program": False}
client.app.dependency_overrides[verify_session] = lambda: {
"company_id": 7,
"user_id": 42,
"is_master": True,
"role": "ADMIN",
}
company = client.put(
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "company"}
)
assert company.status_code == 200, company.text
saved = json.loads(
(storage / "7" / "library" / f"{company.json()['code']}.json").read_text("utf-8")
)
assert saved["library_tier"] == "company"
assert (
client.put(
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
).status_code
== 403
)
client.app.dependency_overrides[verify_session] = lambda: {
"company_id": None,
"user_id": 1,
"role": "SYSTEM_ADMIN",
}
program = client.put(
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
)
assert program.status_code == 200, program.text
assert program.json()["code"] == load_template("masonry_wet")["code"] # 씨앗 코드로 덮어씀
assert (library_module.program_library_dir() / f"{program.json()['code']}.json").is_file()
assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()["can_publish"][
"program"
]
def test_프로그램_기본_발행본엔_원문_공사명_파일명을_안_싣는다(tmp_path: Path) -> None:
"""2026-09-14 브레인 ② — 모든 회사로 가는 발행본에서만 가림 · 원본·회사 단은 그대로(③)."""
origin = {
"kind": "stmate_xlsx",
"file": "봉화.xlsx",
"project": "2024년 봉화 임도",
"hopyo_no": 6,
}
template = {**load_template("masonry_wet"), "origin": origin}
program = library_module.save_personal(tmp_path / "program", template, None, None, "program")
company = library_module.save_personal(tmp_path / "company", template, None, None, "company")
published = json.loads((tmp_path / "program" / f"{program}.json").read_text(encoding="utf-8"))
kept = json.loads((tmp_path / "company" / f"{company}.json").read_text(encoding="utf-8"))
assert "project" not in published["origin"] and "file" not in published["origin"]
assert published["origin"]["masked"] == "원문에서 뽑음 — 공사명은 발행 시 가림"
assert published["origin"]["kind"] == "stmate_xlsx"
assert kept["origin"] == origin
assert template["origin"] == origin # 원본은 안 건드림
def test_발행_확인창이_가릴_공사명을_보인다() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
assert "originProject" in ui and "공사명은 빼고 발행됩니다" in ui
sheet_ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(
encoding="utf-8"
)
assert "originProject: sheet.library_item.origin_project" in sheet_ui
template_py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_StructureTemplate.py").read_text(
encoding="utf-8"
)
assert '"origin_project"' in template_py
def test_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None:
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
sheet = _sheet(client)
assert (
client.put(f"{SHEETS}/library/personal", json={"sheet_key": sheet["key"]}).status_code
== 403
)
def test_확정_때_기본_양식을_박는다(project: Path) -> None:
assert library_module.pin_program_templates(project) >= 1
pinned = library_module.project_templates(project)["masonry_wet"]
assert pinned["imported_from"] == "program"
assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로
def test_복제는_고른_항목을_개인_단에_베끼고_프로젝트는_안_바꾼다(
client: TestClient, project: Path, storage: Path
) -> None:
"""PLAN 4장 「복제해서 내 것 만들기」 — 기본(또는 회사) 항목을 개인 단으로 한 번에 베낌."""
import B08_Quantity.B08_Quantity_Router_StmateLibrary as extra_router
client.app.include_router(extra_router.router)
base = load_template("masonry_wet")
folder = storage / "7" / "42" / "library"
for path in folder.glob("*.json"):
path.unlink() # 개인 단을 비워 새로 생기는지 봄
response = client.put(
f"{SHEETS}/library/clone",
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
)
assert response.status_code == 200, response.text
code = response.json()["code"]
saved = json.loads((folder / f"{code}.json").read_text(encoding="utf-8"))
assert saved["library_tier"] == "personal" and saved["code"] != base["code"]
assert saved["cloned_from"] == {"tier": "program", "code": base["code"], "name": base["name"]}
assert saved["rows"] == base["rows"]
assert not (project / "B08_Quantity" / "library").exists() # 프로젝트 작업본은 그대로
missing = client.put(
f"{SHEETS}/library/clone",
json={"type_id": "masonry_wet", "tier": "program", "code": "AX-ST-00000000"},
)
assert missing.status_code == 404
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
denied = client.put(
f"{SHEETS}/library/clone",
json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]},
)
assert denied.status_code == 403
def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> None:
"""PLAN 4장 「항목마다 종류 배지」 — 명세 13장: 칸은 같고 `formula` 유무로만 갈림."""
folder = tmp_path / "personal"
folder.mkdir()
fixed = {
"type_id": "masonry_wet",
"code": "AX-ST-0000beef",
"name": "뽑은 항목",
"rows": [{"name": "돌쌓기", "formula": "", "amount": 20.9}],
}
(folder / "AX-ST-0000beef.json").write_text(json.dumps(fixed), encoding="utf-8")
dirs = {"personal": folder, "program": library_module.TEMPLATE_DIR}
kinds = {item["tier"]: item["kind"] for item in library_module.list_items(dirs, "masonry_wet")}
assert kinds == {"personal": "fixed", "program": "form"}
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
assert "양식형" in ui and "고정형" in ui and "item.kind" in ui
def test_복제_단추는_개인_단_항목에선_안_눌린다() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
assert "/clone" in ui and "복제해서 내 것으로" in ui
assert 'clone.disabled = tier === "personal"' in ui
# 발행 단추 — 서버 권한(can_publish)대로만 보임
assert "/publish" in ui and "toCompany.hidden = !canPublish?.company" in ui
assert "toProgram.hidden = !canPublish?.program" in ui
def test_코드_모양이_아니면_박지_않는다(project: Path) -> None:
with pytest.raises(ValueError):
library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")