- 브레인 정함: 서버가 칸 차례 정본 · 조건 칸 → 값 칸 · 범위 조건 · 범위 값(「20~45」)은 「이름·아래」 「이름·위」 - 그 칸에 수 하나면 아래 · 위에 같은 수 · 빈 칸은 빈 칸 · 블록 왼위 = 「표」 바깥 알약 칸 - f8d777628(값 칸 한 칸 · 「80,100」 글)을 되돌림 · 시험 기대값 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
250 lines
10 KiB
Python
250 lines
10 KiB
Python
"""시트 로직 시험 길(`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, "증가율": [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": []}
|