"""M01 「만들기」 API — 절 목록 · 절 거르기 · 표 조건 값 · 소유 거름 · 자체 로직 · 끝수. 계약 `resources/master_data/_화면_계약.md` 2장·5장·6장. 임시 폴더 사본에서 씀. """ from __future__ import annotations import shutil 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 from M01_MasterData import M01_MasterData_Store_Make as make REAL = store.FOLDER LABOR = "LB000002" # 보통인부 SAMPLE = "GF000219" # 산림 13-4-1 메쌓기(인력) @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) 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 _draft(끝수=None) -> dict: """보통인부 1/7 인 한 줄짜리 초안 — 저장 안 함.""" return { "원문번호": "", "이름": "시험 초안", "결과단위": "원/㎡", "출처": "자체", "소유": "현장", "입력": [], "중간": [], "호표": [ { "종류": "인력", "요소": LABOR, "이름": "보통인부", "단위": "인", "수량": "1 / 7", "비목": "노무비", } ], "덧줄": [], "끝수": 끝수, } # ── 끝수 ────────────────────────────────────────────────────────────── def test_끝수_묶음이_계에_붙고_글자는_안_붙는다(client: TestClient) -> None: plain = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft()}) assert plain["ok"] is True, plain raw = Decimal(str(plain["sums"]["계"])) assert raw != raw.to_integral_value() # 172068 / 7 — 원 미만이 남음 cut = _post( client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft({"대상": "계", "자리": 0, "방법": "버림"})}, ) assert Decimal(str(cut["sums"]["계"])) == raw.to_integral_value(rounding="ROUND_DOWN") assert cut["sums"]["노무비"] == cut["sums"]["계"] # 비목 합마다 끊고 계 = 그 합 text = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft("원 미만 버림")}) assert Decimal(str(text["sums"]["계"])) == raw # 글자 끝수는 설명일 뿐 def test_끝수_묶음_모양을_검사가_잡는다() -> None: bad = {"키": "GX000001", "원문번호": "", "호표": [], "끝수": {"대상": "줄", "방법": "내림"}} found = store.cm.mf.check_logic(store.cm.master(REAL), bad)[0] assert any("대상" in x for x in found) and any("방법" in x for x in found) # ── 절 목록 · 절 거르기 · 표 조건 값 ────────────────────────────────── def test_절_목록과_절로_표_거르기(client: TestClient) -> None: got = _get(client, "/api/m01/sections", book="산림품셈", chapter="13") one = next(s for s in got["sections"] if s["section"] == "13-4-1") assert one["title"].startswith("메쌓기") and one["chapter"] == "13장 구조물" assert one["tables"] >= 1 and one["logics"] >= 1 assert client.get("/api/m01/sections", params={"book": "없는품셈"}).status_code == 404 hits = _get(client, "/api/m01/tables", group="소요량", section="13-4", size=500)["tables"] assert hits and all(store.in_section(t["원문번호"], "13-4") for t in hits) assert not store.in_section("13-40", "13-4") and store.in_section("13-4", "13-4") def test_표_조건_값만_받는다(client: TestClient) -> None: listed = _get(client, "/api/m01/tables", group="소요량", section="13-4-1", size=50, q="메쌓기")[ "tables" ] one = listed[0] got = _get(client, "/api/m01/table/options", file=one["file"], key=one["키"]) assert got["count"] >= 1 and "줄" not in got for name, kind in got["조건"].items(): assert name in got["값"] if kind == "고르기": assert len(got["값"][name]) == len(set(map(str, got["값"][name]))) def test_로직_목록을_소유로_거른다(client: TestClient) -> None: every = _get(client, "/api/m01/logics")["logics"] assert all("소유" in x for x in every) only = _get(client, "/api/m01/logics", owner="공용")["logics"] assert only and all(x["소유"] == "공용" for x in only) assert _get(client, "/api/m01/logics", owner="현장")["logics"] == [] # ── 자체 로직 ───────────────────────────────────────────────────────── def test_자체_로직_만들기_본뜨기_고치기_지우기(client: TestClient) -> None: made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"}) assert made["key"].startswith("GX") and made["file"] == make.OWN assert made["logic"]["구분"] == "자체" and made["logic"]["소유"] == "현장" assert (store.FOLDER / make.OWN).is_file() again = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"}) assert again["key"] != made["key"] # 원문번호가 같아도 키는 하나씩 copied = _post(client, "/api/m01/logic/copy", {"key": SAMPLE, "이름": "메쌓기 본뜸"}) assert copied["key"] != SAMPLE and f"{SAMPLE} 를 본뜸" in copied["logic"]["비고"] assert copied["logic"]["이름"] == "메쌓기 본뜸" and copied["logic"]["소유"] == "현장" assert copied["logic"]["상세구분"] == "13장 구조물" fixed = dict(copied["logic"], 이름="고친 이름") done = _post( client, "/api/m01/logic/edit", {"key": copied["key"], "version": copied["version"], "logic": fixed}, ) assert done["logic"]["이름"] == "고친 이름" and done["logic"]["키"] == copied["key"] stale = {"key": copied["key"], "version": copied["version"], "logic": fixed} _post(client, "/api/m01/logic/edit", stale, want=409) gone = _post( client, "/api/m01/logic/delete", {"key": copied["key"], "version": done["version"]} ) assert gone["file"] == make.OWN assert client.get("/api/m01/logic", params={"key": copied["key"]}).status_code == 404 def test_정본_로직은_못_고친다(client: TestClient) -> None: got = _get(client, "/api/m01/logic", key=SAMPLE) body = {"key": SAMPLE, "version": got["version"], "logic": got["logic"]} _post(client, "/api/m01/logic/edit", body, want=403) _post(client, "/api/m01/logic/delete", {"key": SAMPLE, "version": got["version"]}, want=403) _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "남의것"}, want=400) def test_만든_자체_로직이_시험_계산에_돈다(client: TestClient) -> None: made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "공용"}) got = _post(client, "/api/m01/calc", {"key": made["key"], "inputs": {}}) assert got["ok"] is True and Decimal(str(got["sums"]["노무비"])) > 0 listed = _get(client, "/api/m01/logics", owner="공용")["logics"] assert made["key"] in [x["키"] for x in listed]