"""구조물도 양식 라이브러리 — 가져오기·박기 (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_회사_없는_사람은_개인_단을_못_쓴다(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_코드_모양이_아니면_박지_않는다(project: Path) -> None: with pytest.raises(ValueError): library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")