"""시트 로직 시험 길(`M01_MasterData/SheetLogic/M01_SL_Router.py`) 시험 — PLAN 35-5. · 임시 `_data` 로 저장 → 열기 → 목록 → 지우기 · 번호 SL-0001 꼴 · 저장 때 바깥 값을 다시 넣고 서버가 같은 엔진(Node)으로 다시 셈 — 브라우저 계산값은 버림 · 바깥 값 — 견본 표(범위 조건은 두 칸) · 노임 · 기존 로직 결과 · 틀린 종류 · 권한 없음 403 · 마스터 사본 파일은 안 바뀜 견본 표 · 로직 · 통합문서는 시험 안에 둠(임시 폴더 사본에 견본 표를 더함). """ from __future__ import annotations import json import re import shutil import subprocess 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 ROOT = Path(__file__).resolve().parents[2] REAL = store.FOLDER OWN = "로직_자체.json" SAMPLE = "QF999997" TABLE = { "키": SAMPLE, "원문번호": "", "구분": "", "상세구분": "", "이름": "견본 높이 표", "기준": "1㎡", "출처": "자체", "조건": {"돌": "고르기", "높이": "범위"}, "범위규칙": "초과·이하", "값칸": {"석공": "인", "증가율": "%"}, "줄": [ {"돌": "깬돌", "높이": [None, 3], "석공": 0.5, "증가율": [80, 100]}, {"돌": "견치돌", "높이": 4, "석공": 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]["칸"] head = [table[c]["값"] for c in ("A1", "B1", "C1", "D1", "E1", "F1")] assert head == ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"] # A1 부터 assert ( "B2" not in table and table["C2"]["값"] == 3 and table["F2"]["값"] == 100 ) # 빈 끝은 빈 칸 assert table["B3"]["값"] == table["C3"]["값"] == 4 and "E3" not in table # 수 하나 → 같은 수 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() ) # 블록 왼위 = 표 상자 「표」 바깥 알약 칸(고정 A1 아님 — Import 는 E1) at_e1 = _logic(labor) at_e1["nodes"][1]["pillsOut"][0]["cell"] = "E1" second = _post(client, "/api/m01/sl/save", {"logic": at_e1}) assert second["key"] == "SL-0002" moved = second["logic"]["workbook"]["시트"][1]["칸"] assert moved["E1"]["값"] == "돌" and moved["J2"]["값"] == 100 and "A1" not in moved 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, 80, 100], ["견치돌", 4, 4, 0.7, None, None], ] 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": []} def _node_json(script: str) -> object: result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT, ) # fmt: skip assert result.returncode == 0, result.stderr return json.loads(result.stdout) @pytest.mark.skipif(NO_NODE, reason="node 가 없음") def test_표_블록이_Table_Import_와_같은_열(client: TestClient) -> None: """서버 표 답(머리 줄 + 줄) = sub3 Table_Build `blockCells` = sub6 Import_Parse `tableRows`.""" got = _post( client, "/api/m01/sl/outside", {"items": [{"id": "t", "kind": "표", "table": SAMPLE}]} ) rows = got["values"][0]["rows"] table = json.dumps( {"조건": TABLE["조건"], "값칸": TABLE["값칸"], "줄": TABLE["줄"]}, ensure_ascii=False ) info = json.dumps( {"conds": TABLE["조건"], "vals": TABLE["값칸"], "rows": TABLE["줄"]}, ensure_ascii=False ) build = (ROOT / "M01_MasterData/SheetLogic/M01_SL_Table_Build.ts").as_uri() cells = _node_json( f"import {{ blockCells }} from '{build}';console.log(JSON.stringify(blockCells({info})));" ) letters = "ABCDEFGHIJ" mine = { f"{letters[c]}{r + 1}": {"값": v} for r, line in enumerate(rows) for c, v in enumerate(line) if v is not None } assert cells == mine src = (ROOT / "M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts").read_text(encoding="utf-8") body = re.search(r"/\*\* 범위 칸 꼬리[\s\S]*?\nexport function tableRows[\s\S]*?\n}\n", src) assert body, "Import_Parse 의 tableCols · tableRows 를 못 찾음" script = ( "const currentLanguageIndex = 0;\n" + body.group(0).replace("export ", "") + f"\nconsole.log(JSON.stringify(tableRows({table})));" ) result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == rows def _material_cond() -> tuple[dict, str]: """값 있는 자재품목 한 줄 → 검색어 묶음 · 단위(사본에서 읽음).""" rows = json.loads((store.FOLDER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"] row = next(r for r in rows if isinstance(r.get("물가자료"), (int, float)) and r.get("구분")) cond = { "구분": row["구분"], "상세구분": row["상세구분"], "검색어": f"{row['이름']} {row['규격'] or ''}".strip(), } return cond, row["단위"] @pytest.mark.skipif(NO_NODE, reason="node 가 없음") def test_자재_검색어_묶음_단가는_calc_재료_줄과_같음(client: TestClient) -> None: cond, unit = _material_cond() line = { "종류": "재료", "요소": cond, "이름": "재료", "단위": unit, "수량": "1", "비목": "재료비", } row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체", "입력": [], "중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"] assert want is not None text = json.dumps(cond, ensure_ascii=False) # sub6 가져오기 꼴 — 묶음 JSON 글 bad = json.dumps({**cond, "검색어": "없는품목가나다"}, ensure_ascii=False) items = [ {"id": "s", "kind": "자재", "key": text, "unit": unit}, {"id": "d", "kind": "자재", "key": cond, "unit": unit}, {"id": "x", "kind": "자재", "key": bad, "unit": unit}, ] got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]} assert got["s"]["ok"] and got["s"]["value"] == want == got["d"]["value"] assert got["x"]["ok"] is False and got["x"]["reason"] == "「없는품목가나다」 맞는 자재 없음" # 저장 때도 같은 길 — Item · Import 꼴(몸의 outside 목록) logic = { "key": None, "name": "자재 견본", "resultUnit": "원/㎡", "workbook": { "종류": "통합문서", "판": 1, "서식": [{}], "시트": [{"id": "s1", "이름": "재료", "칸": {}}], }, "nodes": [], "edges": [], "outside": [{"item": items[0], "sheet": "s1", "cell": "B2"}], } saved = _post(client, "/api/m01/sl/save", {"logic": logic}) assert saved["logic"]["workbook"]["시트"][0]["칸"]["B2"]["값"] == want _post(client, "/api/m01/sl/delete", {"key": saved["key"]})