"""204-2 B09 설계내역서 연결 고르기 — 프로젝트 호표 + 서고(마스터 · 회사 · 개인) 구조물조합 · 사전조합. - 서고 고름 = 프로젝트 사본 들여옴(`B09_Estimation/v1/library/{폴더}`) + 새 대가 호표(`libs`) + 줄 연결 `{tab: dg, key: 블록 id, lib: 폴더}` · 다시 고르면 같은 사본(만듦 거짓 · 같은 호표) - 사본 단가 묶음(로직 · 사전조합 자재 · 고른 자재) · 원본을 지워도 그대로 · 단가 수요 키에 듦 - [저장] = 줄이 안 잇는 사본 지움 · 덮개 저장 꼴이 `lib` 를 지킴 - 층 권한 = `M02_Layer_Access`(남의 회사 403 · 회사 없는 개인 409) - 화면 — 연결 메뉴 출처 다섯 · 사전조합 창(서고 줄만) → 들여오기 → 원가 자료 바꿔 끼움 · 줄 연결 임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. """ import asyncio import json import re import subprocess import sys from pathlib import Path import pytest from fastapi import HTTPException ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402 from B09_Estimation import B09_Estimation_Router as router # noqa: E402 from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402 from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 from config import config_system # noqa: E402 from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402 from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402 STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡ NET = "GC000337" # 낙석방지망 — 고름 4 = NET_ITEM NET_ITEM = "MT006697" MAT = "MT000008" COMBO = "SS000011" # 견본 관공 관매설(옛 UA 조합 줄) IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) @pytest.fixture() def world(tmp_path, monkeypatch): system = copy_master_sample(tmp_path / "master") monkeypatch.setattr(store, "FOLDER", system) monkeypatch.setattr(layers, "SYSTEM_ROOT", system) monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) lines = [ {"로직": STONE, "수량": {"손": 2}}, {"로직": NET, "수량": {"손": 1}, "고름": {"4": NET_ITEM}}, ] with store.at(system): key = refs.create( "precombo", {"이름": "서고 조합", "단위": "㎡", "담은로직": lines, "자재": [{"코드": MAT, "수량": {"손": 3}}]}, )["이름"] # fmt: skip company = layers.company_dir(7) company.mkdir(parents=True) with store.at(company): other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"] project = tmp_path / "storage" / "7" / "1" / "proj" project.mkdir(parents=True) return {"system": system, "key": key, "company": other, "root": project} def _block(root, folder): got = daega_inputs(root) bid = got["libs"][folder] return got, next(b for b in got["blocks"] if b["id"] == bid) def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사용(world): root = world["root"] made = library.import_copy(root, "precombo", "system", None, world["key"]) folder = made["폴더"] assert made == {"폴더": f"precombo@system@-@{world['key']}", "만듦": True} copy = library.library_dir(root) / folder assert {p.name for p in copy.iterdir()} == {"일위대가.json", "단가.json", "manifest.json"} got, block = _block(root, folder) assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡") kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]] assert kinds == [("sangeun", 2.0), ("sangeun", 1.0), ("material", 3.0)] assert got["owners"] == {} # 구조물 사본 없어도 호표 # 단가 묶음 = 담은 로직 · 사전조합 자재 · 고른 자재(사본 셈이 마스터를 안 읽음) bundle = json.loads((copy / "단가.json").read_text("utf-8")) text = json.dumps(bundle, ensure_ascii=False) assert all(code in text for code in (STONE, NET, MAT, NET_ITEM)) keys = linked_resource_keys(root) assert MAT in keys and any(k.startswith("LB") for k in keys) # 단가 수요에 듦 # 다시 고름 = 새로 안 들여옴 · 같은 호표 again = library.import_copy(root, "precombo", "system", None, world["key"]) assert again == {"폴더": folder, "만듦": False} assert _block(root, folder)[1]["id"] == block["id"] # 원본을 지워도 굳힌 사본 그대로 (world["system"] / "precombo" / f"{world['key']}.json").unlink() assert _block(root, folder)[1]["id"] == block["id"] def test_구조물조합_고름_사본_조합_호표(world): root = world["root"] made = library.import_copy(root, "combo", "system", None, COMBO) copy = library.library_dir(root) / made["폴더"] names = {p.name for p in copy.iterdir()} assert {"집계.json", "일위대가.json", "단가.json", "입력값.json", "manifest.json"} <= names manifest = json.loads((copy / "manifest.json").read_text("utf-8")) assert manifest["종류"] == "combo" and manifest["원본"]["key"] == COMBO assert manifest["원본"]["경로"] # 가져온 때 경로 _, block = _block(root, made["폴더"]) assert block["kind"] == "조합" and block["lines"] assert block["origin"] == {"layer": "system", "owner": None, "key": COMBO} def test_없는_것_모양_틀림(world): root = world["root"] with pytest.raises(LookupError): library.import_copy(root, "precombo", "system", None, "SC-999999") with pytest.raises(LookupError): library.import_copy(root, "combo", "system", None, "SS999999") with pytest.raises(ValueError): library.import_copy(root, "precombo", "system", None, "../x") with pytest.raises(ValueError): library.import_copy(root, "other", "system", None, world["key"]) assert library.copies(root) == [] def test_저장_때_안_잇는_사본_지움_lib_지킴(world): root = world["root"] a = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"] b = library.import_copy(root, "combo", "system", None, COMBO)["폴더"] ref = {"tab": "dg", "key": "DG:x", "lib": a} user = store_b09.clean_user( {"bill": {"v": 2, "rows": [{"key": "hand:1", "name": "줄", "qty": 1, "ref": ref}]}} ) assert user["bill"]["rows"][0]["ref"] == ref # 저장 꼴이 lib 를 지킴 assert library.prune(root, user) == [b] assert [f.name for f, _ in library.copies(root)] == [a] assert library.prune(root, {}) == [a] and library.copies(root) == [] def _call(monkeypatch, root, session, **body): async def fake_root(_): return root async def fake_payload(_, at): return {"inputs": {"daega": daega_inputs(at)}, "sheets": {}} monkeypatch.setattr(router, "_project_root", fake_root) monkeypatch.setattr(router, "payload", fake_payload) req = router.LibraryBody(**body) return asyncio.run(router.import_library("00000000-0000-0000-0000-000000000001", req, session)) def test_층_권한_길(world, monkeypatch): root = world["root"] mine = {"user_id": 1, "company_id": 7, "role": "USER", "name": "갑"} got = _call(monkeypatch, root, mine, kind="precombo", layer="company", key=world["company"]) assert got["만듦"] and got["폴더"] == f"precombo@company@7@{world['company']}" assert got["key"] == got["payload"]["inputs"]["daega"]["libs"][got["폴더"]] assert json.loads((library.library_dir(root) / got["폴더"] / "manifest.json").read_bytes())[ "가져온이" ] == {"id": 1, "이름": "갑"} other = {"user_id": 2, "company_id": 8, "role": "USER"} with pytest.raises(HTTPException) as error: _call(monkeypatch, root, other, kind="precombo", layer="company", owner="7", key=world["company"]) # fmt: skip assert error.value.status_code == 403 lonely = {"user_id": 3, "company_id": None, "role": "USER"} with pytest.raises(HTTPException) as error: _call(monkeypatch, root, lonely, kind="precombo", layer="personal", key=world["key"]) assert error.value.status_code == 409 with pytest.raises(HTTPException) as error: _call(monkeypatch, root, lonely, kind="precombo", layer="system", key="SC-999999") assert error.value.status_code == 404 got = _call(monkeypatch, root, lonely, kind="precombo", layer="system", key=world["key"]) assert got["만듦"] and got["key"] # 마스터 = 누구나 읽음 # ── 화면 ────────────────────────────────────────────────────────────── LINK_JS = """ const toasts = []; const sent = []; globalThis.showToast = (m, k) => toasts.push([k, m]); globalThis.API_BASE_URL = "/api"; globalThis.CURRENT_PROJECT_ID_KEY = "pid"; globalThis.localStorage = { getItem: () => "P1" }; let picker = null; // 251-6 단가연결 = 한 창(openLogicPicker · 사전조합 + 덧붙인 줄 구조물 조합) globalThis.openLogicPicker = (done, lib) => { picker = lib; sent.push(["pre", lib.layer, lib.kindFilter.map((k) => k.value), lib.title]); }; globalThis.loadRows = async () => [{ key: "SS000011", layer: "system", owner: null, 단위: "m" }]; globalThis.itemName = () => "관매설"; const answer = { 폴더: "precombo@company@7@SC-000002", 만듦: true, key: "DG:new", payload: { user: { bill: "서버 것" }, inputs: { daega: { libs: {} } }, sheets: { daega: [{ key: "DG:new", name: "회사 조합", spec: "", unit: "㎡" }] } } }; globalThis.fetch = async (url, init) => { sent.push([url, JSON.parse(init.body)]); return { ok: true, status: 200, json: async () => answer }; }; const ctx = { sheets: {}, payload: { inputs: { old: 1 } }, user: { keep: 1 }, setUser() {} }; const applied = []; const apply = (ref, from) => applied.push([ref, from && from.name]); const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply); const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) }; picks[0][1](); await new Promise((r) => setTimeout(r, 0)); out.combo = picker.extra.filter((x) => x.kind === "combo").map((x) => [x.tag, x.name, x.sub]); picker.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); await new Promise((r) => setTimeout(r, 0)); picker.extra.find((x) => x.kind === "combo").onPick(); await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); Object.assign(out, { sent, applied, payload: ctx.payload, toasts }); console.log(JSON.stringify(out)); """ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): src = IMPORTS.sub( "", (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8") ) result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], input=src + LINK_JS, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr out = json.loads(result.stdout) assert out["picks"] == ["단가연결"] # 251-6 고르기 다섯 = 단가연결 창 하나 assert out["more"] == ["단가연결 풀기"] pre, (url, body) = out["sent"][0], out["sent"][1] kinds = ["logic", "precombo", "combo", "dg", "sg"] assert pre == ["pre", "personal", kinds, "단가연결"] # 층 셋 단추 · 종류 고르기 assert out["combo"] == [["구조물 조합", "관매설", "마스터 · SS000011"]] assert url == "/api/projects/P1/estimation/library" assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"} assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None, "key": "SS000011"} # fmt: skip ref = {"tab": "dg", "key": "DG:new", "lib": "precombo@company@7@SC-000002"} assert out["applied"][0] == [ref, "회사 조합"] # 원가 자료만 바꿔 끼움 — 사용자 값(서버 user)은 안 받음 assert "user" not in out["payload"] and out["payload"]["inputs"] == {"daega": {"libs": {}}} assert out["toasts"][-1][0] == "success"