"""시트 로직 시험 길(`M01_MasterData/SheetLogic/M01_SL_Router.py`) 시험 — PLAN 35-5. · 임시 `_data` 로 저장 → 열기 → 목록 → 지우기 · 번호 SL-0001 꼴 · 저장 때 바깥 값을 다시 넣고 서버가 같은 엔진(Node)으로 다시 셈 — 브라우저 계산값은 버림 · 바깥 값 — 견본 표(범위 조건은 두 칸) · 노임 · 기존 로직 결과 · 틀린 종류 · 권한 없음 403 · 마스터 사본 파일은 안 바뀜 견본 표 · 로직 · 통합문서는 시험 안에 둠(임시 폴더 사본에 견본 표를 더함). """ from __future__ import annotations import json import shutil from decimal import Decimal from pathlib import Path import pytest from fastapi import Depends, FastAPI from fastapi.testclient import TestClient from common_util.common_util_auth import require_system_admin, verify_session from M01_MasterData import M01_MasterData_Router as router_module from M01_MasterData import M01_MasterData_Store as store from M01_MasterData.SheetLogic import M01_SL_Router as sl REAL = store.FOLDER OWN = "로직_자체.json" SAMPLE = "QF999997" TABLE = { "키": SAMPLE, "원문번호": "", "구분": "", "상세구분": "", "이름": "견본 높이 표", "기준": "1㎡", "출처": "자체", "조건": {"돌": "고르기", "높이": "범위"}, "범위규칙": "초과·이하", "값칸": {"석공": "인"}, "줄": [ {"돌": "깬돌", "높이": [None, 3], "석공": 0.5}, {"돌": "깬돌", "높이": [3, None], "석공": 0.7}, ], "주": [], } NO_NODE = shutil.which("node") is None def _labor() -> tuple[str, Decimal]: rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"] row = next( r for r in rows if r.get("상태") == "공표" and not r.get("준용") and isinstance(r.get("값"), (int, float)) ) return row["키"], Decimal(str(row["값"])) @pytest.fixture def folder(tmp_path: Path, monkeypatch) -> Path: master = tmp_path / "master" master.mkdir() for path in REAL.glob("*.json"): if path.name == OWN: continue if not path.name.startswith("_") or path.name == store.mk.BOOK.name: shutil.copy(path, master / path.name) host = sorted(master.glob("소요량_*.json"))[0] data = json.loads(host.read_text(encoding="utf-8")) data["표"].append(TABLE) host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") monkeypatch.setattr(store, "FOLDER", master) monkeypatch.setattr(sl, "DATA", tmp_path / "_data") return master @pytest.fixture def client(folder: Path) -> TestClient: app = FastAPI() app.include_router(router_module.router) app.include_router(sl.router) return TestClient(app) def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: res = client.post(url, json=body) assert res.status_code == want, res.text return res.json() def _logic(labor: str, key: str | None = None) -> dict: """Model SheetLogic — 노무 상자(source 노임 · A1) × 2 = B1 · 표 상자(ref 로 짐작 · A1 부터) · 출처 없는 바깥 알약은 뺌 · 몸의 outside 목록(Item 꼴)은 C2 에 · 브라우저 계산값은 엉터리.""" return { "key": key, "name": "견본 시트 로직", "resultUnit": "원/㎡", "workbook": { "종류": "통합문서", "판": 1, "서식": [{}], "시트": [ {"id": "s1", "이름": "노무", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}}, {"id": "s2", "이름": "표", "칸": {}}, ], "계산값": {"s1": {"B1": {"수": "999"}}}, }, "nodes": [ { "id": "n1", "kind": "item", "name": "노무 석공", "sheetId": "s1", "stage": 3, "pillsIn": [{"name": "노임", "cell": "A1", "outside": True}], "pillsOut": [{"name": "금액", "cell": "B1"}], "source": {"kind": "노임", "key": labor}, }, { "id": "n2", "kind": "table", "name": "견본 표", "sheetId": "s2", "stage": 1, "pillsIn": [], "pillsOut": [{"name": "표", "cell": "A1", "outside": True}], "ref": SAMPLE, }, { "id": "n3", "kind": "calc", "name": "계산", "sheetId": "s1", "stage": 2, "pillsIn": [], "pillsOut": [{"name": "x", "cell": "C1", "outside": True}], }, ], # fmt: skip "edges": [ {"id": "e1", "from": {"node": "n2", "pill": "표"}, "to": {"node": "n1", "pill": "노임"}} ], "outside": [ {"item": {"id": "n1:노무비", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "C2"} ], } @pytest.mark.skipif(NO_NODE, reason="node 가 없음") def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, folder: Path) -> None: labor, price = _labor() before = {p.name: p.read_bytes() for p in folder.glob("*.json")} saved = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)}) assert saved["key"] == "SL-0001" book = saved["logic"]["workbook"] cells = book["시트"][0]["칸"] assert cells["A1"]["값"] == price == cells["C2"]["값"] # 바깥 값을 마스터에서 다시 넣음(①②) assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림) assert "C1" not in cells # 출처 없는 바깥 알약은 안 넣음 table = book["시트"][1]["칸"] assert [table[c]["값"] for c in ("A1", "B1", "C1", "D1")] == [ "돌", "높이·아래", "높이·위", "석공", ] assert ( "B2" not in table and table["C2"]["값"] == 3 and table["D3"]["값"] == 0.7 ) # 빈 끝은 빈 칸 assert saved["logic"]["edges"] == _logic(labor)["edges"] answers = {v["id"]: v for v in saved["values"]} assert set(answers) == {"n1!A1", "n2!A1", "n1:노무비"} and all( v["ok"] for v in answers.values() ) second = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)}) assert second["key"] == "SL-0002" renamed = {**saved["logic"], "name": "고친 이름"} assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001" listed = client.get("/api/m01/sl/list").json()["logics"] assert [(x["key"], x["이름"]) for x in listed] == [ ("SL-0001", "고친 이름"), ("SL-0002", "견본 시트 로직"), ] got = client.get("/api/m01/sl/get", params={"key": "SL-0001"}).json()["logic"] assert got["workbook"]["계산값"] == book["계산값"] _post(client, "/api/m01/sl/delete", {"key": "SL-0002"}) assert client.get("/api/m01/sl/get", params={"key": "SL-0002"}).status_code == 404 _post(client, "/api/m01/sl/delete", {"key": "SL-0002"}, want=404) _post(client, "/api/m01/sl/save", {"logic": _logic(labor, "SL-0009")}, want=404) _post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "name": " "}}, want=400) assert client.get("/api/m01/sl/get", params={"key": "../x"}).status_code == 400 assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로 assert sorted(p.name for p in sl.DATA.iterdir()) == ["SL-0001.json"] def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None: labor, price = _labor() line = { "종류": "인력", "요소": labor, "이름": "석공", "단위": "인", "수량": "품", "비목": "노무비", } row = {"원문번호": "", "이름": "견본 노무", "결과단위": "원/㎡", "출처": "자체", "입력": [{"이름": "품"}], "중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip made = _post(client, "/api/m01/logic/new", {"logic": row}) items = [ {"id": "t", "kind": "표", "table": SAMPLE}, {"id": "l", "kind": "노임", "key": labor}, {"id": "g", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}}, {"id": "g2", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}, "field": "재료비"}, {"id": "w", "kind": "자재", "key": labor}, {"id": "x", "kind": "표", "table": "QF999996"}, {"id": "z", "kind": "모름"}, ] got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]} assert got["t"]["rows"][0] == ["돌", "높이·아래", "높이·위", "석공"] assert got["t"]["rows"][1:] == [["깬돌", None, 3, 0.5], ["깬돌", 3, None, 0.7]] assert Decimal(str(got["l"]["value"])) == price and got["l"]["unit"] assert Decimal(str(got["g"]["value"])) == Decimal("0.5") * price assert got["g2"]["value"] == 0 for k in ("w", "x", "z"): assert got[k]["ok"] is False and got[k]["reason"], got[k] def test_시스템_관리자가_아니면_403(folder: Path) -> None: app = FastAPI() app.include_router( sl.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] ) app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} client = TestClient(app) assert client.get("/api/m01/sl/list").status_code == 403 assert client.post("/api/m01/sl/outside", json={"items": []}).status_code == 403 app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None} assert client.get("/api/m01/sl/list").json() == {"logics": []}