Files
Aislo/resources/tester/test_m01_easycalc_api.py
T
eomsangdonandClaude Opus 5.5 87aabc4a0d feat(m01-easycalc): 쉬운 단가산출 Api 몸 — 서버 길 감싸기 · 기계 짝 · 쉬운 말
- 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
2026-10-01 09:42:02 +09:00

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