- listEasy(품셈 기본 / 내가 만든 것 · 돈 아닌 로직 「쉬운 길로 못 엶」) · openEasy(자동값) · calcLater(0.3초 · 최신 응답만) - newEasy · copyEasy · editEasy · deleteEasy — /logic/* 만(/save 안 씀) · usesOf = 담은 조합 + 부르는 내 로직 - listMachines · machineElement — 기종 → 기계 시간당 경비 로직 부름 글 · 넘길 설계 값(부착 전용 · 손료만은 뒤로) - plainError — 0 · 403 · 404 · 409 · 422 검사 글을 쉬운 말로(키 · 날 글 걸름) - 시험 test_m01_easycalc_api.py — 사본 폴더 왕복 · 쓰는 곳 막음 · 기계 짝 · 쉬운 말 · 금지 낱말 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
322 lines
15 KiB
Python
322 lines
15 KiB
Python
"""M01 쉬운 단가산출 Api(`M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts`) 시험 — PLAN 34-3b.
|
|
|
|
· 서버 길 왕복 — Api 가 부르는 몸 그대로 새로 → 고치기 → 시험 계산 → 지우기(임시 폴더 사본에서만)
|
|
· 쓰는 곳 — 담은 조합 · 부르는 내 로직이 지우기를 막음
|
|
· 기계 짝 규칙 · 쉬운 말 바꾸기 — Api 파일의 「순수」 칸을 Node 로 바로 돌림
|
|
견본 로직 줄은 시험 안에 둠 — 단가 값은 사본에서 읽어 비교(진짜 값에 안 기댐).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
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
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
API = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Api.ts"
|
|
REAL = store.FOLDER
|
|
OWN = "로직_자체.json"
|
|
NO_NODE = shutil.which("node") is None
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
|
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, tmp_path / path.name)
|
|
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
|
app = FastAPI()
|
|
app.include_router(router_module.router)
|
|
return TestClient(app)
|
|
|
|
|
|
def _call(client: TestClient, url: str, body: dict | None = None, want: int = 200) -> dict:
|
|
res = client.get(url) if body is None else client.post(url, json=body)
|
|
assert res.status_code == want, res.text
|
|
return res.json()
|
|
|
|
|
|
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 isinstance(r.get("값"), (int, float)))
|
|
return row["키"], Decimal(str(row["값"]))
|
|
|
|
|
|
def _row(name: str, lines: list[dict], inputs: list | None = None) -> dict:
|
|
"""쉬운 길이 보내는 최소 줄 — 키 · 갈래 · 소유는 서버가 채움."""
|
|
return {
|
|
"키": "",
|
|
"원문번호": "",
|
|
"이름": name,
|
|
"결과단위": "원/㎡",
|
|
"출처": "자체",
|
|
"입력": inputs or [],
|
|
"중간": [],
|
|
"호표": lines,
|
|
"덧줄": [],
|
|
"끝수": None,
|
|
}
|
|
|
|
|
|
def _person(key: str, qty: str) -> dict:
|
|
return {
|
|
"종류": "인력",
|
|
"요소": key,
|
|
"이름": "사람",
|
|
"단위": "인",
|
|
"수량": qty,
|
|
"비목": "노무비",
|
|
}
|
|
|
|
|
|
def _node(expr: str) -> object:
|
|
"""Api 파일의 「순수」 칸 + 식 하나를 Node 로 돌려 JSON 답."""
|
|
src = API.read_text(encoding="utf-8")
|
|
block = re.search(r"/\* ── 순수 시작[^\n]*\n([\s\S]*?)/\* ── 순수 끝", src)
|
|
assert block, "순수 칸 표시를 못 찾음"
|
|
script = (
|
|
"const currentLanguageIndex = 0;\n"
|
|
+ block.group(1).replace("export function", "function")
|
|
+ f"\nconsole.log(JSON.stringify({expr}));"
|
|
)
|
|
result = subprocess.run(
|
|
["node", "--experimental-strip-types", "--no-warnings",
|
|
"--input-type=module-typescript", "-e", script],
|
|
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
|
) # fmt: skip
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
# ── 서버 길 왕복 ───────────────────────────────────────────────────────
|
|
def test_새로_고치기_시험계산_지우기_왕복(client: TestClient) -> None:
|
|
real_before = (REAL / OWN).exists()
|
|
key_l, price = _labor()
|
|
|
|
made = _call(
|
|
client,
|
|
"/api/m01/logic/new",
|
|
{"logic": _row("시험 쉬운 줄", [_person(key_l, "0.5")]), "owner": "현장"},
|
|
)
|
|
key = made["key"]
|
|
assert key.startswith("GX") and made["file"] == OWN and made["logic"]["소유"] == "현장"
|
|
|
|
# 목록 · 열기 — 내 것은 사본의 자체 파일에서
|
|
listed = _call(client, "/api/m01/logics?q=시험 쉬운 줄")["logics"]
|
|
assert [x["file"] for x in listed] == [OWN] and listed[0]["결과단위"].startswith("원")
|
|
opened = _call(client, f"/api/m01/logic?key={key}")
|
|
assert opened["version"] == made["version"]
|
|
|
|
# 고치기 → 시험 계산(저장 전 줄을 그대로 보냄) — 금액 = 수량 × 노임
|
|
row = {**opened["logic"], "호표": [_person(key_l, "2")]}
|
|
edited = _call(
|
|
client, "/api/m01/logic/edit", {"key": key, "version": opened["version"], "logic": row}
|
|
)
|
|
calc = _call(client, "/api/m01/calc", {"key": key, "inputs": {}, "row": edited["logic"]})
|
|
assert calc["ok"] is True
|
|
assert Decimal(str(calc["sums"]["노무비"])) == 2 * price == Decimal(str(calc["sums"]["계"]))
|
|
|
|
# 저장 안 한 새 줄도 키 "" 로 셈
|
|
draft = _call(
|
|
client,
|
|
"/api/m01/calc",
|
|
{"key": "", "inputs": {}, "row": _row("새 것", [_person(key_l, "1")])},
|
|
)
|
|
assert Decimal(str(draft["sums"]["계"])) == price
|
|
|
|
# 낡은 판본 409 · 품셈 기본 고치기 403
|
|
_call(
|
|
client,
|
|
"/api/m01/logic/edit",
|
|
{"key": key, "version": made["version"], "logic": row},
|
|
want=409,
|
|
)
|
|
base = next(x for x in _call(client, "/api/m01/logics")["logics"] if x["file"] != OWN)
|
|
base_one = _call(client, f"/api/m01/logic?key={base['키']}")
|
|
body = {"key": base["키"], "version": base_one["version"], "logic": base_one["logic"]}
|
|
_call(client, "/api/m01/logic/edit", body, want=403)
|
|
|
|
_call(client, "/api/m01/logic/delete", {"key": key, "version": edited["version"]})
|
|
assert client.get(f"/api/m01/logic?key={key}").status_code == 404
|
|
assert (REAL / OWN).exists() == real_before # 진짜 마스터 폴더는 안 건드림
|
|
|
|
|
|
def test_쓰는_곳_조합과_부르는_로직이_지우기를_막음(client: TestClient) -> None:
|
|
key_l, _ = _labor()
|
|
a = _call(client, "/api/m01/logic/new", {"logic": _row("바탕 줄", [_person(key_l, "1")])})
|
|
call = {"종류": "로직", "요소": f"로직({a['key']})", "이름": "바탕", "단위": "㎡", "수량": "1"}
|
|
b = _call(client, "/api/m01/logic/new", {"logic": _row("부르는 줄", [call])})
|
|
|
|
# 부르는 내 로직 — usesOf 가 훑는 목록(구분 「자체」) · 몸에 「로직(<키>」
|
|
mine = _call(client, "/api/m01/logics?sub=자체")["logics"]
|
|
assert {x["키"] for x in mine} == {a["key"], b["key"]}
|
|
body = json.dumps(_call(client, f"/api/m01/logic?key={b['key']}")["logic"], ensure_ascii=False)
|
|
assert f"로직({a['key']}" in body
|
|
|
|
blocked = client.post("/api/m01/logic/delete", json={"key": a["key"], "version": b["version"]})
|
|
assert blocked.status_code == 422, blocked.text
|
|
errors = blocked.json()["detail"]["errors"]
|
|
if not NO_NODE: # 서버 글 그대로 쉬운 말로 — 키가 안 새어 나감
|
|
plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})")
|
|
assert "지울 수 없음" in plain and not re.search(r"[A-Z]{2}\d{6}", plain)
|
|
|
|
# 부르는 줄을 지우면 바탕을 지울 수 있음
|
|
_call(client, "/api/m01/logic/delete", {"key": b["key"], "version": b["version"]})
|
|
now = _call(client, f"/api/m01/logic?key={a['key']}")["version"]
|
|
|
|
# 담은 조합(마스터 층 파일)도 막음 — /logic/combos 가 그 조합을 줌
|
|
combo_file = store.FOLDER / "일위대가조합.json"
|
|
combos = (
|
|
json.loads(combo_file.read_text(encoding="utf-8"))
|
|
if combo_file.exists()
|
|
else {"그룹": "일위대가조합", "판": "2026", "줄": []}
|
|
)
|
|
combos["줄"].append(
|
|
{"키": "UA999999", "이름": "시험 조합", "구분": "자체", "상세구분": "", "단위": "㎡",
|
|
"담은로직": [{"로직": a["key"], "메모": None}], "본뜬키": None, "출처": "자체",
|
|
"소유": "현장", "비고": None}
|
|
) # fmt: skip
|
|
combo_file.write_text(json.dumps(combos, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
store.cache.stale(["일위대가조합.json"], set())
|
|
used = _call(client, f"/api/m01/logic/combos?key={a['key']}")["combos"]
|
|
assert [c["키"] for c in used] == ["UA999999"]
|
|
_call(client, "/api/m01/logic/delete", {"key": a["key"], "version": now}, want=422)
|
|
|
|
|
|
# ── 기계 짝 규칙 ───────────────────────────────────────────────────────
|
|
def _m(key: str, codes: list[str], *extra: str) -> dict:
|
|
inputs = [{"이름": "기계", "고르기": codes}] + [{"이름": n, "고르기": ["가"]} for n in extra]
|
|
return {"key": key, "name": key, "resultUnit": "원/시간", "inputs": inputs}
|
|
|
|
|
|
FAMILY = [
|
|
_m("M_GENERAL", ["A", "B", "C", "D"], "지역"),
|
|
_m("M_BOOST", ["A", "B", "C", "D", "E"], "지역", "보정작업"),
|
|
_m("M_DIGGER", ["B"], "지역", "보정작업"),
|
|
_m("M_NOOP", ["C"], "지역"),
|
|
_m("M_CLAW", ["B"], "집게", "지역", "보정작업"),
|
|
_m("M_LOSS", ["A", "F", "G"]),
|
|
_m("M_LOSS_BOOST", ["F", "G", "H"], "보정작업"),
|
|
]
|
|
|
|
|
|
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
|
def test_기계_짝_규칙() -> None:
|
|
fam = json.dumps(FAMILY, ensure_ascii=False)
|
|
picks = _node(
|
|
f"['A','B','C','D','E','F','H','Z'].map((c) => pickMachine({fam}, c)?.key ?? null)"
|
|
)
|
|
# 일반 > 보정 · 전용(굴착기 · 조종원 없음)이 일반보다 먼저 · 부착 전용은 안 고름 ·
|
|
# 손료만은 다른 길이 없을 때만 · 손료만 안에서는 보정 아닌 것 먼저 · 없는 기종 null
|
|
assert picks == [
|
|
"M_GENERAL",
|
|
"M_DIGGER",
|
|
"M_NOOP",
|
|
"M_GENERAL",
|
|
"M_BOOST",
|
|
"M_LOSS",
|
|
"M_LOSS_BOOST",
|
|
None,
|
|
]
|
|
|
|
call = _node(f"machineCall({fam}[2], 'B')")
|
|
assert call["element"] == "로직(M_DIGGER, 기계='B', 지역=지역, 보정작업=보정작업)"
|
|
assert [i["이름"] for i in call["needInputs"]] == ["지역", "보정작업"]
|
|
quoted = _node(
|
|
"machineCall({key:'K', name:'', resultUnit:'원/시간', inputs:[{이름:'기계'},{이름:'조종 직종'}]}, 'X')"
|
|
)
|
|
assert quoted["element"] == "로직(K, 기계='X', '조종 직종'='조종 직종')"
|
|
|
|
|
|
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
|
def test_기계_부름글이_서버_검사와_계산을_지남(client: TestClient) -> None:
|
|
"""사본의 기계 시간당 경비 로직으로 짝을 지어 새 줄에 넣음 — 저장 · 계산이 됨(종류 「기계」 꼴 안 만듦)."""
|
|
family = []
|
|
for path in store.FOLDER.glob("로직_*건설기계*.json"):
|
|
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
|
|
if row["결과단위"].startswith("원"):
|
|
family.append(
|
|
{
|
|
"key": row["키"],
|
|
"name": row["이름"],
|
|
"resultUnit": row["결과단위"],
|
|
"inputs": row["입력"],
|
|
}
|
|
)
|
|
if not family:
|
|
pytest.skip("사본에 기계 시간당 경비 로직이 없음")
|
|
fam = json.dumps(family, ensure_ascii=False)
|
|
code = next(
|
|
str(c)
|
|
for m in family
|
|
for i in m["inputs"]
|
|
if i["이름"] == "기계"
|
|
for c in i.get("고르기", [])
|
|
)
|
|
got = _node(
|
|
f"(() => {{ const m = pickMachine({fam}, '{code}'); return m && machineCall(m, '{code}'); }})()"
|
|
)
|
|
assert got and got["element"].startswith("로직(")
|
|
|
|
line = {"종류": "로직", "요소": got["element"], "이름": "기계", "단위": "시간", "수량": "0.5"}
|
|
made = _call(
|
|
client, "/api/m01/logic/new", {"logic": _row("기계 시험", [line], got["needInputs"])}
|
|
)
|
|
trial = {i["이름"]: (i.get("고르기") or [None])[0] for i in got["needInputs"]}
|
|
calc = _call(client, "/api/m01/calc", {"key": made["key"], "inputs": trial})
|
|
assert calc["ok"] is True, calc
|
|
assert Decimal(str(calc["sums"]["계"])) > 0 and "비목" in calc["lines"][0]
|
|
_call(client, "/api/m01/logic/delete", {"key": made["key"], "version": made["version"]})
|
|
|
|
|
|
# ── 쉬운 말 ─────────────────────────────────────────────────────────────
|
|
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
|
def test_plainError_상태와_검사글을_쉬운_말로() -> None:
|
|
simple = _node("[0, 403, 404, 409, 500].map((s) => plainOf(s, 'x'))")
|
|
assert simple[0].startswith("서버와 이어지지")
|
|
assert "바탕으로 고치기" in simple[1] and "찾을 수 없음" in simple[2]
|
|
assert "다른 곳에서 먼저 고쳤음" in simple[3] and "검사에 걸림" in simple[4]
|
|
|
|
errors = [
|
|
"로직_자체.json · GX000001 호표 사람 수량 · 모르는 이름 「면적」",
|
|
"로직_자체.json · GX000001 호표 사람 · 비목 「None」",
|
|
"로직_자체.json · GX000001 시험 · 「호표」 도 「결과」 도 없음",
|
|
"로직_자체.json · GX000001 호표 기계 요소 · 「기계경비」 입력 「지역」 안 줌",
|
|
"알 수 없는 글 GX000009 찾기(QF000001).값",
|
|
]
|
|
plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})").split("\n")
|
|
assert plain[0] == "「면적」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요"
|
|
assert "넣을 자리" in plain[1] and "줄이 하나도 없음" in plain[2]
|
|
assert plain[3] == "그 밖 2 건" # 셋까지 + 나머지 수
|
|
assert not any(re.search(r"[A-Z]{2}\d{6}|찾기\(", x) for x in plain)
|
|
assert "「지역」" in _node(
|
|
f"plainOf(422, {json.dumps({'errors': errors[3:4]}, ensure_ascii=False)})"
|
|
)
|
|
assert _node("plainOf(400, '로직 줄에 「이름」 이 없음')") == "이름을 넣어 주세요"
|
|
assert _node("plainStop('「GX000001」 입력 「돌」 없음')").startswith("이 값으로는")
|
|
|
|
|
|
def test_화면_글에_금지_낱말이_없음() -> None:
|
|
"""설계 1절 8 — 키 · 식 · 호표 · 덧줄 · 비목 · 중간 값 · 끝수 · 정본 · 자체 · 별칭 안 씀(한국어 글)."""
|
|
src = API.read_text(encoding="utf-8")
|
|
block = re.search(r"const TEXT = \{([\s\S]*?)\} as const", src)
|
|
assert block
|
|
korean = re.findall(r'\[\s*"([^"]*)",', block.group(1))
|
|
assert len(korean) > 20
|
|
banned = ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭")
|
|
for text in korean:
|
|
assert not any(word in text for word in banned), text
|