Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
255 lines
13 KiB
Python
255 lines
13 KiB
Python
# ruff: noqa: E501
|
||
"""흐름도 적용(PLAN 36-4 서버 · 정함 1 · 2 · 4 · 10 · 11 · 12) — 로직 줄 「흐름」 칸.
|
||
|
||
못박는 것(마스터 사본 폴더에서만 · 정본은 안 바뀜)
|
||
- `/save` 줄에 실은 흐름 = 검사 → 서버 다시 셈(받는 칸에 값) → 파일에 씀 · 다시 읽으면 그대로
|
||
- 흐름 꼴이 틀리면(없는 알약 · 돌고 도는 선) 422 · 빈 받는 자리는 경고
|
||
- `/calc` 갈래 — 흐름 있으면 흐름 셈(입력 = 설계 때 정할 것 · 노무비 · 재료비 · 경비 · 계) · 없으면 옛 셈
|
||
- 새 줄(키 없음) + 흐름 → 자체 파일 GX 다음 키 · 구분 자체 · 본떠 만들기는 흐름도 함께
|
||
- `GET /logic/flow` — 흐름 있으면 그대로 · 없으면 변환(준비 전이면 빈 흐름 + 「변환 준비 중」)
|
||
- DataFlow 다른 로직 값 · 설계 입력이 마스터 키도 받음(흐름 · 옛 로직) · 서로 부르면 막음
|
||
- 시스템 관리자가 아니면 403
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import importlib.util
|
||
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 m01_router
|
||
from M01_MasterData import M01_MasterData_Store as store
|
||
from M01_MasterData import M01_MasterData_Store_Flow as sflow
|
||
from M02_MasterTemplete.DataFlow import M02_DF_Router as df
|
||
|
||
REAL = store.FOLDER
|
||
KEY = "GF000219" # 13-4-1 메쌓기 — 흐름을 얹어 볼 정본 로직
|
||
OLD = "GF000160" # 12-4 합판거푸집 — 흐름 없는 옛 로직
|
||
OWN = "로직_자체.json"
|
||
|
||
|
||
def _flow(name: str = "넓이 품") -> dict:
|
||
"""길이 × 폭 × 1000 → 노무비 — 입력 상자(변수 둘) · 함수 상자 · 출력 상자."""
|
||
sheet = {"id": "s-fn", "이름": "넓이", "칸": {"A2": {"값": "길이"}, "A3": {"값": "폭"},
|
||
"A4": {"값": "금액"}, "B4": {"식": "B2*B3*1000"}}} # fmt: skip
|
||
return {
|
||
"key": None, "name": name, "resultUnit": "원/㎡", "userElements": [],
|
||
"boxes": [
|
||
{"id": "in", "kind": "input", "name": "치수", "color": "", "unit": "-", "x": 0, "y": 0,
|
||
"input": {"mode": "variable", "elements": [], "variables": [
|
||
{"name": "길이", "type": "number", "unit": "m", "example": 2},
|
||
{"name": "폭", "type": "number", "unit": "m", "example": 3}]}},
|
||
{"id": "fn", "kind": "function", "name": "넓이 금액", "color": "", "unit": "원", "x": 0, "y": 0,
|
||
"func": {"sheet": sheet, "inputs": [{"port": "길이", "cell": "B2"}, {"port": "폭", "cell": "B3"}],
|
||
"variables": [], "output": {"cell": "B4", "name": "금액", "unit": "원"}}},
|
||
{"id": "out", "kind": "output", "name": "노무비", "color": "", "unit": "원/㎡", "x": 0, "y": 0,
|
||
"output": {"cost": "노무비", "unit": "원/㎡"}},
|
||
],
|
||
"links": [
|
||
{"id": "l1", "from": {"box": "in", "name": "길이"}, "to": {"box": "fn", "name": "길이"}},
|
||
{"id": "l2", "from": {"box": "in", "name": "폭"}, "to": {"box": "fn", "name": "폭"}},
|
||
{"id": "l3", "from": {"box": "fn", "name": "금액"}, "to": {"box": "out", "name": "값"}},
|
||
],
|
||
} # fmt: skip
|
||
|
||
|
||
def _hashes(folder: Path) -> dict[str, str]:
|
||
return {p.name: hashlib.sha1(p.read_bytes()).hexdigest() for p in folder.glob("*.json")}
|
||
|
||
|
||
@pytest.fixture
|
||
def folder(tmp_path: Path, monkeypatch) -> Path:
|
||
master = tmp_path / "master"
|
||
master.mkdir()
|
||
for path in REAL.glob("*.json"):
|
||
shutil.copy(path, master / path.name)
|
||
monkeypatch.setattr(store, "FOLDER", master)
|
||
monkeypatch.setattr(df, "DATA", tmp_path / "_data")
|
||
return master
|
||
|
||
|
||
@pytest.fixture
|
||
def client(folder: Path) -> TestClient:
|
||
app = FastAPI()
|
||
app.include_router(m01_router.router)
|
||
app.include_router(df.router)
|
||
return TestClient(app)
|
||
|
||
|
||
def _get(client: TestClient, url: str, want: int = 200, **params) -> dict:
|
||
res = client.get(url, params=params)
|
||
assert res.status_code == want, res.text
|
||
return res.json()
|
||
|
||
|
||
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 _save_flow(client: TestClient, key: str, flow: dict, want: int = 200) -> dict:
|
||
got = _get(client, "/api/m01/logic", key=key)
|
||
row = {**got["logic"], "흐름": flow}
|
||
part = {"file": got["file"], "version": got["version"],
|
||
"changes": [{"op": "edit", "key": key, "row": row}]} # fmt: skip
|
||
return _post(client, "/api/m01/save", {"files": [part]}, want)
|
||
|
||
|
||
def test_흐름_저장_다시_셈_다시_읽기(client: TestClient, folder: Path) -> None:
|
||
before = _hashes(REAL)
|
||
done = _save_flow(client, KEY, _flow())
|
||
assert done["warnings"] == [] and done["added"] == []
|
||
saved = _get(client, "/api/m01/logic", key=KEY)["logic"]["흐름"]
|
||
cells = saved["boxes"][1]["func"]["sheet"]["칸"]
|
||
assert (
|
||
Decimal(str(cells["B2"]["값"])) == 2 and Decimal(str(cells["B3"]["값"])) == 3
|
||
) # 서버가 넣은 값
|
||
assert "key" not in saved
|
||
got = _get(client, "/api/m01/logic/flow", key=KEY)
|
||
assert got["converted"] is False and got["notes"] == [] and got["flow"]["key"] == KEY
|
||
assert [b["id"] for b in got["flow"]["boxes"]] == ["in", "fn", "out"]
|
||
assert _hashes(REAL) == before # 사본 밖(정본)은 안 바뀜
|
||
|
||
|
||
def test_흐름_꼴_검사_422_와_빈_자리_경고(client: TestClient) -> None:
|
||
bad = _flow()
|
||
bad["links"][0]["from"]["name"] = "없는알약"
|
||
assert "없는 알약" in str(_save_flow(client, KEY, bad, 422))
|
||
loop = _flow()
|
||
loop["boxes"].append({**copy.deepcopy(loop["boxes"][1]), "id": "fn2", "name": "되돌이"})
|
||
loop["links"] += [
|
||
{"id": "l4", "from": {"box": "fn", "name": "금액"}, "to": {"box": "fn2", "name": "길이"}},
|
||
{"id": "l5", "from": {"box": "fn2", "name": "금액"}, "to": {"box": "fn", "name": "폭"}},
|
||
]
|
||
loop["links"] = [x for x in loop["links"] if x["id"] != "l2"]
|
||
assert "돌고 도는" in str(_save_flow(client, KEY, loop, 422))
|
||
empty = _flow()
|
||
empty["links"] = [x for x in empty["links"] if x["id"] != "l2"]
|
||
warns = _save_flow(client, KEY, empty)["warnings"]
|
||
assert any("넓이 금액 · 폭" in w for w in warns), warns
|
||
|
||
|
||
def test_계산_갈래_흐름이면_흐름_셈_아니면_옛_셈(client: TestClient) -> None:
|
||
_save_flow(client, KEY, _flow())
|
||
got = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {"길이": 4, "폭": "5"}})
|
||
assert got["flow"] is True and got["ok"] is True
|
||
assert {k: Decimal(str(v)) for k, v in got["sums"].items()} == {
|
||
"노무비": 20000, "재료비": 0, "경비": 0, "계": 20000} # fmt: skip
|
||
assert (
|
||
got["outputs"][0]["name"] == "노무비" and Decimal(str(got["outputs"][0]["value"])) == 20000
|
||
)
|
||
plain = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {}}) # 예시값 2 × 3
|
||
assert Decimal(str(plain["sums"]["계"])) == 6000
|
||
row = {**_get(client, "/api/m01/logic", key=KEY)["logic"], "흐름": _flow()} # 저장 전 줄
|
||
row["흐름"]["boxes"][1]["func"]["sheet"]["칸"]["B4"] = {"식": "B2*B3*10"}
|
||
draft = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {}, "row": row})
|
||
assert Decimal(str(draft["sums"]["계"])) == 60
|
||
old = _post(client, "/api/m01/calc", {"key": OLD, "inputs": {}})
|
||
assert "flow" not in old # 흐름 없는 로직은 옛 셈 그대로
|
||
|
||
|
||
def test_신규_흐름_로직은_자체_GX_복사는_흐름도(client: TestClient, folder: Path) -> None:
|
||
version = store.read(OWN)[1] if (folder / OWN).is_file() else ""
|
||
row = {"키": "", "이름": "시험 흐름 로직", "결과단위": "원/㎡", "출처": "", "구분": "산림품셈",
|
||
"흐름": _flow("시험 흐름 로직")} # fmt: skip
|
||
part = {"file": OWN, "version": version, "changes": [{"op": "add", "row": row}]}
|
||
done = _post(client, "/api/m01/save", {"files": [part]})
|
||
assert len(done["added"]) == 1 and done["added"][0]["file"] == OWN
|
||
key = done["added"][0]["key"]
|
||
assert key.startswith("GX")
|
||
saved = _get(client, "/api/m01/logic", key=key)["logic"]
|
||
assert saved["구분"] == "자체" and saved["흐름"]["name"] == "시험 흐름 로직"
|
||
assert (
|
||
Decimal(str(_post(client, "/api/m01/calc", {"key": key, "inputs": {}})["sums"]["계"]))
|
||
== 6000
|
||
)
|
||
made = _post(client, "/api/m01/logic/copy", {"key": key, "이름": "복사한 흐름"})
|
||
assert made["key"].startswith("GX") and made["key"] != key
|
||
copied = _get(client, "/api/m01/logic/flow", key=made["key"])
|
||
assert copied["converted"] is False and len(copied["flow"]["boxes"]) == 3
|
||
bad = {"file": OWN, "version": store.read(OWN)[1], "changes": [{"op": "add", "row": row}]}
|
||
bad["changes"][0]["row"] = {**row, "흐름": {"boxes": "아님"}}
|
||
_post(client, "/api/m01/save", {"files": [bad]}, 422)
|
||
|
||
|
||
def test_흐름_없는_로직_받기는_변환(client: TestClient) -> None:
|
||
got = _get(client, "/api/m01/logic/flow", key=OLD)
|
||
assert got["converted"] is True and got["flow"]["key"] == OLD
|
||
if importlib.util.find_spec(sflow.CONVERT) is None: # sub7 변환이 오기 전
|
||
assert got["notes"] == [sflow.NOT_READY] and got["flow"]["boxes"] == []
|
||
_get(client, "/api/m01/logic/flow", 404, key="GX999999")
|
||
|
||
|
||
def test_다른_로직_값_마스터_키_흐름_옛_순환(client: TestClient) -> None:
|
||
_save_flow(client, KEY, _flow())
|
||
got = _post(client, "/api/m02/df/logic-value", {"key": KEY, "designs": {"길이": 10}})
|
||
assert Decimal(str(got["outputs"]["노무비"])) == 30000 and got["errors"] == []
|
||
designs = _get(client, "/api/m02/df/design-inputs", key=KEY)["designs"]
|
||
assert [d["port"] for d in designs] == ["길이", "폭"]
|
||
given = {k: str(v) for k, v in store.cm.시험입력(store.logic(OLD)["logic"]).items()}
|
||
old = _post(client, "/api/m02/df/logic-value", {"key": OLD, "designs": given})
|
||
want = _post(client, "/api/m01/calc", {"key": OLD, "inputs": given})
|
||
assert old["outputs"] == want["sums"] and set(old["outputs"]) == {
|
||
"노무비",
|
||
"재료비",
|
||
"경비",
|
||
"계",
|
||
}
|
||
names = [d["port"] for d in _get(client, "/api/m02/df/design-inputs", key=OLD)["designs"]]
|
||
assert names == [x["이름"] for x in store.logic(OLD)["logic"].get("입력") or []]
|
||
# 흐름 안 다른 로직 입력이 옛 로직(OLD) 계 를 받아 노무비로 · 자기 자신을 부르면 막힘
|
||
flow = _flow()
|
||
pills = [{"name": k, "unit": "-"} for k in given]
|
||
ref = {"key": OLD, "outputs": [{"name": "계", "unit": "원"}], "designs": pills}
|
||
flow["boxes"].append({"id": "lg", "kind": "input", "name": "거푸집", "color": "", "unit": "-",
|
||
"x": 0, "y": 0, "input": {"mode": "logic", "elements": [], "variables": [],
|
||
"logic": ref}}) # fmt: skip
|
||
flow["boxes"].append({**copy.deepcopy(flow["boxes"][2]), "id": "out2", "name": "경비"})
|
||
flow["boxes"][-1]["output"]["cost"] = "경비"
|
||
flow["links"].append(
|
||
{"id": "l9", "from": {"box": "lg", "name": "계"}, "to": {"box": "out2", "name": "값"}}
|
||
)
|
||
_save_flow(client, KEY, flow)
|
||
mixed = _post(client, "/api/m01/calc", {"key": KEY, "inputs": given})
|
||
assert Decimal(str(mixed["sums"]["경비"])) == Decimal(str(want["sums"]["계"]))
|
||
ref["key"] = KEY
|
||
self_call = _save_flow(client, KEY, flow)["warnings"]
|
||
assert any("서로를 부름" in w for w in self_call), self_call
|
||
|
||
|
||
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
|
||
app = FastAPI()
|
||
app.include_router(
|
||
m01_router.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
|
||
)
|
||
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
|
||
client = TestClient(app)
|
||
before = _hashes(folder)
|
||
assert client.get("/api/m01/logic/flow", params={"key": KEY}).status_code == 403
|
||
assert client.post("/api/m01/logic/flow", json={"key": KEY, "row": {}}).status_code == 403
|
||
assert client.post("/api/m01/save", json={"files": []}).status_code == 403
|
||
assert client.post("/api/m01/calc", json={"key": KEY, "inputs": {}}).status_code == 403
|
||
assert _hashes(folder) == before
|
||
|
||
|
||
def test_저장_안_한_고친_줄_변환(client: TestClient, folder: Path) -> None:
|
||
"""POST /logic/flow — 옛 편집기 고친 줄 그대로 변환 · 파일은 안 바뀜 · 줄에 흐름이 있어도 변환."""
|
||
before = _hashes(folder)
|
||
row = copy.deepcopy(_get(client, "/api/m01/logic", key=OLD)["logic"])
|
||
row["이름"] = "고친 이름"
|
||
row["흐름"] = _flow()
|
||
got = _post(client, "/api/m01/logic/flow", {"key": OLD, "row": row})
|
||
assert got["converted"] is True and got["flow"]["key"] == OLD
|
||
if importlib.util.find_spec(sflow.CONVERT) is not None:
|
||
assert got["flow"]["name"] == "고친 이름" and got["flow"]["boxes"]
|
||
assert _hashes(folder) == before
|