Files
Aislo/resources/tester/test_m01_combo.py
T

316 lines
14 KiB
Python

"""M01 일위대가 조합(PLAN 4-2) — 담은 단가산출 로직 목록만 두고 미리 보기로 비목별 합계.
계약 `resources/master_data/_화면_계약.md` 9장 · 틀 `_틀.md` 10장 · 엔진 `scripts/master_combo.py`.
못박는 것
- 조합은 이름과 담은 로직 목록만 지님 — 수량·비율·계산 칸을 두지 않음.
- 미리 보기 합계 = 담은 로직 저마다의 시험 계산을 비목별로 더한 값(저장 없음).
- 검사 — 없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합이 조합을 담음.
- 시험은 임시 사본에서 돌고, 사본의 조합 줄은 비워 시작 — 정본에 견본 조합이 있어도 없어도 같음.
"""
from __future__ import annotations
import json
import shutil
import sys
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts"))
import master_combo as mcb # noqa: E402
REAL = store.FOLDER
STACK = "GF000219" # 13-4-1 돌쌓기 메쌓기(인력)
LAYER = "GF000220" # 13-4-2 돌쌓기 메쌓기(장비)
GATHER = "GF000211" # 13-2-1 모래·자갈·약돌 채집(인력)
SAMPLE = (STACK, LAYER, GATHER) # 견본 조합 — 로직 셋
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
for path in REAL.glob("*.json"):
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
own = tmp_path / mcb.FILE # 정본의 견본 조합이 시험 줄 수에 끼지 않게 사본은 비워 시작
data = json.loads(own.read_text(encoding="utf-8"))
data["줄"] = []
own.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _get(client: TestClient, url: str, **params) -> dict:
res = client.get(url, params=params)
assert res.status_code == 200, 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 _inputs(key: str) -> dict:
"""그 로직의 일괄 시험 입력 한 벌(`check_master.시험입력`)."""
row = store.cm.master(REAL).logic(key)
return {k: str(v) for k, v in store.cm.시험입력(row).items()}
def _sums(client: TestClient, key: str, inputs: dict) -> dict:
got = _post(client, "/api/m01/calc", {"key": key, "inputs": inputs})
assert got["ok"] is True, got
return got["sums"]
def _make(client: TestClient, keys=SAMPLE, 이름="돌쌓기 한 벌", owner: str = "현장") -> dict:
body = {
"이름": 이름,
"구분": "자체",
"상세구분": "13장 구조물",
"단위": "㎡",
"담은로직": [{"로직": k, "메모": None} for k in keys],
}
return _post(client, "/api/m01/combo/new", {"combo": body, "owner": owner})
# ── 정본 파일 ─────────────────────────────────────────────────────────
def test_정본_조합_파일은_틀대로다() -> None:
"""줄 수는 못 박지 않음 — 견본이 있어도 없어도 됨 · 줄은 조합 키(UA)와 열 한 벌만."""
data = store.cm.load([mcb.FILE], folder=REAL)[mcb.FILE]
assert data["그룹"] == mcb.GROUP
assert all(row["키"].startswith("UA") and set(row) <= set(mcb.COLS) for row in data["줄"])
assert "원문" not in data # 관리자가 만드는 것이라 원문 근거가 없음
def test_조합_테이블ID_는_UA_이고_줄에_원문번호를_두지_않는다() -> None:
assert store.mk.table_id(mcb.GROUP, None) == "UA"
assert store.mk.GROUP_OF["UA"] == mcb.GROUP
assert "UA" in store.mk.NO_NUMBER
def test_조합은_요소_그룹이_아니다(client: TestClient) -> None:
"""조합은 제 길(`/combos`·`/combo`)로만 다룸 — 요소 길(`/groups`·`/rows`)에 끼지 않음.
끼우면 화면이 조합을 여느 요소 표처럼 `/rows` 로 펴 버림(줄 모양이 아주 다름).
"""
assert mcb.GROUP not in store.cm.mf.GROUPS
groups = [g["group"] for g in _get(client, "/api/m01/groups")["groups"]]
assert groups == ["인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직"]
assert client.get("/api/m01/groups/일위대가조합/files").status_code == 404
# ── 만들기 · 읽기 ─────────────────────────────────────────────────────
def test_조합을_만들면_키를_서버가_주고_담은_로직에_이름이_붙는다(client: TestClient) -> None:
made = _make(client)
key = made["key"]
assert key.startswith("UA"), made
assert made["combo"]["소유"] == "현장"
assert tuple(made["combo"]) == mcb.COLS # 열 한 벌 · 차례까지
assert "원문번호" not in made["combo"]
got = _get(client, "/api/m01/combo", key=key)
assert [one["로직"] for one in got["줄"]] == list(SAMPLE) # 담은 차례 그대로
assert got["줄"][0]["이름"] == "돌쌓기 메쌓기(인력)"
assert got["줄"][0]["결과단위"] == "원/㎡"
assert got["blocked"] is False and got["reasons"] == []
# 수량·비율·계산은 조합에 두지 않음
assert set(got["combo"]["담은로직"][0]) == {"로직", "메모"}
def test_목록은_구분_소유_찾기로_거른다(client: TestClient) -> None:
_make(client, 이름="돌쌓기 한 벌")
_make(client, keys=(GATHER,), 이름="채집만", owner="공용")
assert len(_get(client, "/api/m01/combos")["combos"]) == 2
assert [c["이름"] for c in _get(client, "/api/m01/combos", q="채집")["combos"]] == ["채집만"]
only = _get(client, "/api/m01/combos", owner="공용")["combos"]
assert [c["이름"] for c in only] == ["채집만"] and only[0]["count"] == 1
def test_이_로직을_쓰는_조합을_거꾸로_본다(client: TestClient) -> None:
first = _make(client, 이름="돌쌓기 한 벌")["key"]
second = _make(client, keys=(STACK,), 이름="메쌓기만")["key"]
got = _get(client, "/api/m01/logic/combos", key=STACK)
assert [c["키"] for c in got["combos"]] == [first, second]
assert got["combos"][0]["차례"] == [1] # 그 조합 안 몇 번째인지
assert _get(client, "/api/m01/logic/combos", key="GF000001")["combos"] == []
# ── 고치기 · 지우기 ───────────────────────────────────────────────────
def test_로직을_빼고_더하고_지울_수_있다(client: TestClient) -> None:
key = _make(client)["key"]
got = _get(client, "/api/m01/combo", key=key)
row = got["combo"]
row["담은로직"] = [{"로직": LAYER, "메모": "장비만 남김"}]
done = _post(
client, "/api/m01/combo/edit", {"key": key, "version": got["version"], "combo": row}
)
assert [one["로직"] for one in done["combo"]["담은로직"]] == [LAYER]
assert done["combo"]["담은로직"][0]["메모"] == "장비만 남김"
assert done["combo"]["키"] == key # 키는 안 바뀜
now = _get(client, "/api/m01/combo", key=key)
_post(client, "/api/m01/combo/delete", {"key": key, "version": now["version"]})
assert _get(client, "/api/m01/combos")["combos"] == []
def test_낡은_판본으로_고치면_409(client: TestClient) -> None:
key = _make(client)["key"]
row = _get(client, "/api/m01/combo", key=key)["combo"]
got = _post(
client,
"/api/m01/combo/edit",
{"key": key, "version": "낡은판본", "combo": row},
want=409,
)
assert got["detail"] == {"stale": [mcb.FILE]}
# ── 검사 ──────────────────────────────────────────────────────────────
def test_없는_로직_키는_저장을_막는다(client: TestClient) -> None:
got = _post(
client,
"/api/m01/combo/new",
{"combo": {"이름": "엉터리", "담은로직": [{"로직": "GF999999", "메모": None}]}},
want=422,
)
assert any("GF999999" in x for x in got["detail"]["errors"]), got
def test_같은_로직을_두_번_담으면_막는다(client: TestClient) -> None:
got = _post(
client,
"/api/m01/combo/new",
{"combo": {"이름": "두 번", "담은로직": [{"로직": STACK}, {"로직": STACK}]}},
want=422,
)
assert any("두 번 담음" in x for x in got["detail"]["errors"]), got
def test_빈_조합은_막는다(client: TestClient) -> None:
got = _post(client, "/api/m01/combo/new", {"combo": {"이름": "빈 것", "담은로직": []}}, 422)
assert any("빈 조합" in x for x in got["detail"]["errors"]), got
def test_조합은_조합을_담지_못한다_1단만(client: TestClient) -> None:
key = _make(client)["key"]
got = _post(
client,
"/api/m01/combo/new",
{"combo": {"이름": "겹친 것", "담은로직": [{"로직": key}]}},
want=422,
)
assert any("조합을 담지 못함" in x for x in got["detail"]["errors"]), got
def test_이름_없는_조합과_모르는_소유는_400(client: TestClient) -> None:
_post(client, "/api/m01/combo/new", {"combo": {"담은로직": [{"로직": STACK}]}}, want=400)
_post(
client,
"/api/m01/combo/new",
{"combo": {"이름": "가", "담은로직": [{"로직": STACK}]}, "owner": "아무개"},
want=400,
)
def test_이름이_글자_아니면_400(client: TestClient) -> None:
"""재현: 이름 ["x"] 가 200 으로 저장됨(일감 24 어긋난 것 2) — 이제 400."""
_post(
client,
"/api/m01/combo/new",
{"combo": {"이름": ["x"], "담은로직": [{"로직": STACK}]}},
want=400,
)
def test_이름이_너무_길면_막는다(client: TestClient) -> None:
got = _post(
client,
"/api/m01/combo/new",
{"combo": {"이름": "가" * 61, "담은로직": [{"로직": STACK}]}},
want=422,
)
assert any("너무 김" in x for x in got["detail"]["errors"]), got
def test_같은_이름_두_번_담으면_막는다(client: TestClient) -> None:
_make(client, keys=(STACK,), 이름="돌쌓기 한 벌")
got = _post(
client,
"/api/m01/combo/new",
{"combo": {"이름": "돌쌓기 한 벌", "담은로직": [{"로직": LAYER}]}},
want=422,
)
assert any("겹침" in x for x in got["detail"]["errors"]), got
def test_메모가_글자_아니면_막는다(client: TestClient) -> None:
"""재현: 메모 {"수량": 3} 이 200 으로 저장됨(일감 24 어긋난 것 1) — 이제 422."""
got = _post(
client,
"/api/m01/combo/new",
{"combo": {"이름": "메모 흠", "담은로직": [{"로직": STACK, "메모": {"수량": 3}}]}},
want=422,
)
assert any("메모」 가 글자 아님" in x for x in got["detail"]["errors"]), got
# ── 미리 보기 ─────────────────────────────────────────────────────────
def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None:
key = _make(client)["key"]
each = {k: _inputs(k) for k in SAMPLE}
want = {
name: sum(Decimal(str(_sums(client, k, each[k])[name])) for k in SAMPLE)
for name in ("노무비", "재료비", "경비", "계")
}
got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": each})
assert got["멈춤"] == [], got["멈춤"]
assert [one["로직"] for one in got["줄"]] == list(SAMPLE)
for name in ("노무비", "재료비", "경비", "계"):
assert Decimal(str(got[name])) == want[name], name
assert Decimal(str(got["계"])) > 0
# 줄마다도 그 로직 하나의 시험 계산과 같음
for one in got["줄"]:
mine = _sums(client, one["로직"], each[one["로직"]])
assert Decimal(str(one["계"])) == Decimal(str(mine["계"]))
def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None:
before = _get(client, "/api/m01/combos")["combos"]
row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]}
got = _post(client, "/api/m01/combo/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}})
assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0
assert _get(client, "/api/m01/combos")["combos"] == before # 아무것도 안 생김
def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None:
key = _make(client, keys=(STACK, LAYER))["key"]
only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤
got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": only})
stuck = [one for one in got["줄"] if not one["ok"]]
assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"]
assert len(got["멈춤"]) == 1
alone = _sums(client, STACK, only[STACK])
assert Decimal(str(got["계"])) == Decimal(str(alone["계"])) # 선 줄만 더함
def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None:
before = _get(client, "/api/m01/logic", key=STACK)
key = _make(client)["key"]
_post(
client, "/api/m01/combo/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}
)
after = _get(client, "/api/m01/logic", key=STACK)
assert after["version"] == before["version"] and after["logic"] == before["logic"]