- 663-1: 수량 · 단가가 만약으로 0 을 탄 금액 줄 = 안 보임(GC000211 폼타이 9.5 · 7.9 ㎜) · 규격 바꾸면 그 줄만 · [선택으로 꺼진 줄 보기] = 고침 모드에서만 · 정의 영역도 꺼진 줄 기호 뺌 1,518 문서 금액 줄 5,912 중 기본 옵션에서 꺼짐 145 줄(107 문서) · 옵션에 따라 꺼질 수 있는 줄 305 줄(141 문서) - 식 안 만약이 0 · 1 을 타면 그 항(+ 0 · × 1)도 안 보임 — 고침 글은 그대로 - 수 하나 · 기호 하나 줄 값 식 = 한 번만(「= 1 = 1」 없음) · 수량 없음 「× 1」 자리는 줄에 마우스 올릴 때만 - 옮김 slim(master_doc_convert) — + 0 · × 1 · ÷ 1 지움 · A × k ÷ 100 → A × k% · 빌더 다시(목록 xlsx 는 안 씀) · 대조 그대로(기본 1,519 같음 · 옵션 다름 1 · 저장 1,518) · 31 파일 글만 - 시험 test_802_3 + 군더더기 없음 1(12) · 802_1 · 802_2 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
9.7 KiB
Python
234 lines
9.7 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""PLAN 802-3 — M01 단가산출 기초 수식 문서 화면 서버 길(요구 660~665 · 요구서_664).
|
||
|
||
① 읽기 {문서, 셈, 연결} · 없는 문서 = 빈 문서 · 없는 키 404
|
||
② 미리 셈 = 쓰기 없음 · 연결 기록(표 통째 · 단가산출 문서 · 요소 값)
|
||
③ 저장 = 서버가 정본으로 다시 셈(보낸 셈 값 무시) · 임시 폴더 파일에만 씀 · 다시 읽어 같음
|
||
④ 시스템 관리자 아니면 막힘(main.py 와 같은 의존성)
|
||
⑤ 지움 견딤 — 기호 · 줄 · 금액 줄 · 연결 대상 지움 · 순환 · 0 나눔 · 목록 밖 · 전부 지움 = 표지 · 금액 없음(500 없음)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import shutil
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi import Depends, FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from common_util import common_util_auth as auth
|
||
from M01_MasterData import M01_MasterData_Doc as doc_screen
|
||
from M01_MasterData import M01_MasterData_Router as router_module
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
KEY = "GC000211" # 합판거푸집 설치 및 해체 — 계 124,419
|
||
|
||
|
||
@pytest.fixture
|
||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||
for path in doc_screen.folder().glob("수식_*.json"):
|
||
shutil.copy(path, tmp_path / path.name)
|
||
monkeypatch.setattr(doc_screen, "folder", lambda: tmp_path)
|
||
doc_screen._memo.clear()
|
||
app = FastAPI()
|
||
app.include_router(router_module.router)
|
||
yield TestClient(app)
|
||
doc_screen._memo.clear()
|
||
|
||
|
||
def _read(c: TestClient, key: str = KEY) -> dict:
|
||
res = c.get("/api/m01/logic/doc", params={"key": key})
|
||
assert res.status_code == 200, res.text
|
||
return res.json()
|
||
|
||
|
||
def _prev(c: TestClient, doc: dict, given: dict | None = None) -> dict:
|
||
res = c.post("/api/m01/logic/doc/preview", json={"문서": doc, "입력": given or {}})
|
||
assert res.status_code == 200, res.text
|
||
return res.json()
|
||
|
||
|
||
def test_읽기_미리셈_연결기록(client):
|
||
got = _read(client)
|
||
assert got["셈"]["계"] == 124419 and got["셈"]["머리"] == "계" and got["새"] is False
|
||
rec = got["연결"]
|
||
assert {"QC000428", "QC000429", "QC000431"} <= set(
|
||
rec["표"]
|
||
) # 표 통째(옵션 바뀌어도 화면이 찾음)
|
||
assert all("줄" in t for t in rec["표"].values() if t)
|
||
assert any(e["p"].get("키") == "LB000007" and "값" in e for e in rec["요소"])
|
||
assert rec["이름"]["QC000431"].startswith("합판거푸집")
|
||
# 옵션(입력) 바꿈 = 문서 안 바뀜 · 값만
|
||
other = _prev(client, got["문서"], {"폼타이": "사용", "측압": "4t/㎡"})
|
||
assert got["셈"]["값"]["Q_조"] == 0
|
||
assert other["셈"]["값"]["Q_조"] == other["셈"]["값"]["Q_4t^표"] > 0
|
||
|
||
|
||
def test_없는문서_빈문서_없는키_404(client):
|
||
fresh = _read(client, "GC000100") # 802-2a 로 문서 안 넣음
|
||
assert fresh["새"] is True and fresh["문서"]["식"] == [] and fresh["문서"]["금액"] == []
|
||
assert fresh["셈"]["셈됨"] is False and fresh["셈"]["계"] is None
|
||
assert client.get("/api/m01/logic/doc", params={"key": "GX999999"}).status_code == 404
|
||
|
||
|
||
def test_저장_서버가_다시셈_파일에씀(client, tmp_path):
|
||
doc = _read(client)["문서"]
|
||
doc["식"].append({"기호": "Z_시험", "식": "3600 × 2 ÷ 4"})
|
||
doc["셈"] = {"계": 1} # 브라우저 값 — 서버는 안 받음
|
||
res = client.post("/api/m01/logic/doc/save", json={"문서": doc})
|
||
assert res.status_code == 200, res.text
|
||
out = res.json()
|
||
assert out["셈"]["값"]["Z_시험"] == 1800 and out["셈"]["계"] == 124419
|
||
assert out["파일"] == "수식_건설품셈_06장_철근콘크리트공사.json"
|
||
assert "Z_시험" in (tmp_path / out["파일"]).read_text(encoding="utf-8")
|
||
again = _read(client)
|
||
assert again["문서"]["식"][-1] == {"기호": "Z_시험", "식": "3600 × 2 ÷ 4"}
|
||
assert again["셈"]["계"] == 124419
|
||
# 정본 폴더는 안 바뀜
|
||
real = (ROOT / "resources/master_data/수식문서" / out["파일"]).read_text(encoding="utf-8")
|
||
assert "Z_시험" not in real
|
||
# 꼴 아님 · 키 없음 = 400
|
||
assert client.post("/api/m01/logic/doc/save", json={"문서": {"꼴": "x"}}).status_code == 400
|
||
assert (
|
||
client.post("/api/m01/logic/doc/save", json={"문서": {"꼴": "수식문서"}}).status_code == 400
|
||
)
|
||
|
||
|
||
def test_새문서_저장_빈데서_시작(client, tmp_path):
|
||
blank = _read(client, "GC000100")["문서"]
|
||
blank["식"] = [{"기호": "Q", "식": "3600 × q ÷ C_m"}]
|
||
blank["변수"] = [
|
||
{"열쇠": "q", "기호": "q", "연결": {"갈래": "숫자", "값": 0.7}},
|
||
{"열쇠": "C_m", "기호": "C", "아래": "m", "연결": {"갈래": "숫자", "값": 18}},
|
||
{"열쇠": "w", "기호": "w", "연결": {"갈래": "요소", "키": "LB000002"}},
|
||
]
|
||
blank["금액"] = [{"이름": "보통인부", "비목": "노무비", "단가": "w ÷ Q"}]
|
||
out = client.post("/api/m01/logic/doc/save", json={"문서": blank}).json()
|
||
assert out["셈"]["셈됨"] and out["셈"]["값"]["Q"] == 140
|
||
assert _read(client, "GC000100")["새"] is False
|
||
|
||
|
||
def test_관리자_아니면_막힘(monkeypatch):
|
||
for role, code in (("USER", 403), ("COMPANY_ADMIN", 403), ("SYSTEM_ADMIN", 200)):
|
||
app = FastAPI()
|
||
app.include_router(
|
||
router_module.router,
|
||
dependencies=[Depends(auth.verify_session), Depends(auth.require_system_admin)],
|
||
)
|
||
app.dependency_overrides[auth.verify_session] = lambda role=role: {
|
||
"role": role,
|
||
"is_master": False,
|
||
"company_id": 1,
|
||
}
|
||
c = TestClient(app)
|
||
assert c.get("/api/m01/logic/doc", params={"key": KEY}).status_code == code
|
||
if code == 403:
|
||
res = c.post("/api/m01/logic/doc/save", json={"문서": {"꼴": "수식문서", "키": KEY}})
|
||
assert res.status_code == 403
|
||
main = (ROOT / "main.py").read_text(encoding="utf-8")
|
||
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
|
||
|
||
|
||
def _cuts(doc: dict):
|
||
"""지운 · 망가뜨린 문서들."""
|
||
for i in range(len(doc["식"])):
|
||
one = copy.deepcopy(doc)
|
||
del one["식"][i] # 줄 하나 지움
|
||
yield one
|
||
one = copy.deepcopy(doc)
|
||
one["식"][i]["식"] = "없는기호 × 2" # 없는 기호
|
||
yield one
|
||
for i in range(len(doc["금액"])):
|
||
one = copy.deepcopy(doc)
|
||
del one["금액"][i]
|
||
yield one
|
||
for i in range(len(doc["변수"])):
|
||
one = copy.deepcopy(doc)
|
||
del one["변수"][i] # 기호 하나 지움 → 정의 필요
|
||
yield one
|
||
one = copy.deepcopy(doc)
|
||
kind = one["변수"][i]["연결"]["갈래"]
|
||
one["변수"][i]["연결"] = {"갈래": kind} # 연결 대상 아직 안 고름
|
||
yield one
|
||
one = copy.deepcopy(doc)
|
||
one["식"].append({"기호": "Q_시공^표", "식": "Q_시공 × 2"}) # 겹침
|
||
yield one
|
||
one = copy.deepcopy(doc)
|
||
for v in one["변수"]:
|
||
if v["열쇠"] == "유형":
|
||
v["연결"]["목록"] = ["보통"] # 목록 밖
|
||
yield one
|
||
one = copy.deepcopy(doc)
|
||
one["변수"], one["식"], one["금액"] = [], [], [] # 전부 지움
|
||
yield one
|
||
|
||
|
||
def test_지움_견딤_표지_금액없음(client):
|
||
doc = _read(client)["문서"]
|
||
n = 0
|
||
for one in _cuts(doc):
|
||
got = _prev(client, one)["셈"]
|
||
n += 1
|
||
if not got["셈됨"]:
|
||
assert got["계"] is None and (got["표지"] or got.get("까닭"))
|
||
assert n > 80
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"line, kind",
|
||
[
|
||
({"기호": "Q_시공", "식": "Q_형틀목공 × 2"}, "순환"),
|
||
({"기호": "Q_시공", "식": "Q_시공^표 ÷ 0"}, "0 나눔"),
|
||
({"기호": "Q_시공", "식": "Q_시공^표 × ("}, "식 틀림"),
|
||
({"기호": "Q_시공", "식": "Z_없음 × 2"}, "정의 필요"),
|
||
],
|
||
)
|
||
def test_표지_갈래(client, line, kind):
|
||
doc = _read(client)["문서"]
|
||
doc["식"] = [line if x["기호"] == line["기호"] else x for x in doc["식"]]
|
||
got = _prev(client, doc)["셈"]
|
||
assert not got["셈됨"] and got["계"] is None
|
||
assert kind in {m["갈래"] for m in got["표지"]}
|
||
|
||
|
||
def test_연결_후보_표(client):
|
||
res = client.post(
|
||
"/api/m01/logic/doc/links", json={"갈래": "단가산출", "검색": "굴착기 시간당"}
|
||
)
|
||
hits = res.json()["후보"]
|
||
assert hits and all("입력" in h for h in hits)
|
||
res = client.post("/api/m01/logic/doc/links", json={"갈래": "표", "검색": "토사깍기 기계"})
|
||
assert any(h["키"] == "QF000249" for h in res.json()["후보"])
|
||
table = client.get("/api/m01/logic/doc/table", params={"key": "QF000249"}).json()
|
||
assert "조건" in table and table["줄"]
|
||
assert "끊김" in client.get("/api/m01/logic/doc/table", params={"key": "LB000002"}).json()
|
||
|
||
|
||
def test_옮김_군더더기_없음():
|
||
"""저장 문서 식 · 금액 글에 `+ 0` · `× 1` · `÷ 1` · `× k ÷ 100` 없음(옮김 `slim` · 값 같음)."""
|
||
import master_doc as md
|
||
import master_doc_convert as cv
|
||
import master_doc_link as ml
|
||
|
||
def junk(n) -> bool:
|
||
if n[0] == "bin":
|
||
a, b = n[2], n[3]
|
||
if n[1] in ("+", "-", "*", "/") and cv.slim(n) != n:
|
||
return True
|
||
return junk(a) or junk(b)
|
||
if n[0] in ("neg", "pct", "field"):
|
||
return junk(n[1])
|
||
return n[0] == "call" and any(junk(x) for x in n[2])
|
||
|
||
bad = []
|
||
for key, doc in ml.load_all().items():
|
||
texts = [x.get("식") for x in doc.get("식") or []]
|
||
texts += [x.get(k) for x in doc.get("금액") or [] for k in ("수량", "단가")]
|
||
for t in texts:
|
||
if t and junk(md.parse(t)):
|
||
bad.append((key, t))
|
||
assert not bad, bad[:5]
|
||
assert md.text_of(cv.slim(md.parse("(r + 0) × 1 × 2 ÷ 100 ÷ 1"))) == "r × 2%"
|