· 단위 칸에 「없음」(맨수 · - · % · 빈 글)을 골라 무차원으로 둘 수 있게 함 — 횟수·배율을 「회」 로 두어 「원/회」 가 남던 덧셈 검사 걸림을 풂 · 같은 단위끼리 나누면 맨 수가 되는 것도 맞음 · 줄을 「이름 = 식」 과 「식 = 이름」(A * B = C) 둘 다 받음 — 한쪽이 값 이름 하나면 그쪽이 이름 · 양쪽이 다 식이면 그 줄의 등호 자리를 짚음 · 괄호 안 등호와 비교 기호는 안 가름 · 비목 안 고른 덧줄을 「못 옮김」 으로 막지 않고 그 값의 「정할것」 에 「비목」 을 얹어 짚어 줌 · 돈 아닌 로직은 덧줄이 없어 안 물음 · 계약 _화면_계약.md 8장(다른 창이 7장을 먼저 씀) · 시험 test_m01_draft.py 18개 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
298 lines
15 KiB
Python
298 lines
15 KiB
Python
"""식으로 새 로직 만들기 — 변수 뽑기 · 검사 · 단위 · 로직 틀로 옮기기 (`_화면_계약.md` 7장).
|
||
|
||
견본 셋 — 자재비 식(제대로 도는 것) · 단위가 어긋난 식 · 서로 물고 도는 식.
|
||
해석기 `resources/master_data/scripts/master_draft.py` · 서버 길 `POST /api/m01/logic/draft`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
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
|
||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts"))
|
||
import master_draft as md # noqa: E402
|
||
|
||
REAL = store.FOLDER
|
||
LABOR = "LB000002" # 보통인부
|
||
|
||
# ① 자재비 식 — 넓이 → 품 → 보통인부 한 줄 + 잡재료비 덧줄
|
||
GOOD = """넓이 = 길이 * 폭
|
||
인부 = 넓이 * 품
|
||
잡재료비 = 노무비 * 0.05
|
||
공사비 = 인부 * 보통인부 + 잡재료비
|
||
"""
|
||
GOOD_정함 = {
|
||
"길이": {"무엇": "설계입력", "단위": "m", "설명": "쌓을 길이"},
|
||
"폭": {"무엇": "설계입력", "단위": "m", "설명": "쌓을 폭"},
|
||
"품": {"무엇": "고정값", "값": "0.15", "단위": "인/㎡"},
|
||
"보통인부": {"무엇": "마스터요소", "요소": LABOR, "단위": "인", "비목": "노무비"},
|
||
"잡재료비": {"비목": "재료비"},
|
||
}
|
||
GOOD_머리 = {"이름": "시험 자재비", "결과단위": "원/㎡", "출처": "자체"}
|
||
|
||
# ② 단위가 어긋난 식 — 길이(m) 와 넓이(㎡) 를 더함
|
||
BAD_UNIT = """넓이 = 길이 * 폭
|
||
틀린값 = 길이 + 넓이
|
||
"""
|
||
BAD_UNIT_정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "폭": {"무엇": "설계입력", "단위": "m"}}
|
||
|
||
# ③ 서로 물고 도는 식
|
||
LOOP = """가 = 나 + 1
|
||
나 = 가 * 2
|
||
끝 = 나
|
||
"""
|
||
|
||
|
||
@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 _post(client: TestClient, body: dict, want: int = 200) -> dict:
|
||
res = client.post("/api/m01/logic/draft", json=body)
|
||
assert res.status_code == want, res.text
|
||
return res.json()
|
||
|
||
|
||
def _var(got: dict, name: str) -> dict:
|
||
return next(v for v in got["변수"] if v["이름"] == name)
|
||
|
||
|
||
# ── ① 변수 뽑기 · 정할 것 ─────────────────────────────────────────────
|
||
def test_변수를_뽑고_정할_것을_알려_준다() -> None:
|
||
got = md.read(GOOD) # 아직 아무것도 안 정함
|
||
assert got["결과"] == "공사비" and got["다됨"] is False
|
||
assert _var(got, "넓이")["갈래"] == "정의됨" and _var(got, "넓이")["정의줄"] == 1
|
||
assert _var(got, "길이")["갈래"] == "미정" and _var(got, "길이")["처음"] == 1
|
||
assert _var(got, "보통인부")["처음"] == 4 # 처음 나온 줄
|
||
assert _var(got, "길이")["정할것"] == ["무엇", "단위"]
|
||
assert _var(got, "노무비")["갈래"] == "비목합" and _var(got, "노무비")["정할것"] == []
|
||
|
||
정함 = dict(GOOD_정함, 보통인부={"무엇": "마스터요소", "요소": LABOR, "단위": "인"})
|
||
assert _var(md.read(GOOD, 정함), "보통인부")["정할것"] == ["비목"]
|
||
assert md.read(GOOD, GOOD_정함)["다됨"] is True
|
||
|
||
|
||
def test_표_찾기는_변수_하나로_두고_자리만_만든다() -> None:
|
||
정함 = {"석공": {"무엇": "표찾기", "단위": "인/㎡"}}
|
||
assert _var(md.read("품 = 넓이 * 석공", 정함), "석공")["정할것"] == ["찾기"]
|
||
이은 = {
|
||
"석공": {
|
||
"무엇": "표찾기",
|
||
"단위": "인/㎡",
|
||
"찾기": {"표": "QF000421", "값칸": "석공", "조건": {"뒷길이": "뒷길이"}},
|
||
}
|
||
}
|
||
assert _var(md.read("품 = 넓이 * 석공", 이은), "석공")["정할것"] == []
|
||
assert md.find_text(이은["석공"]["찾기"]) == "찾기(QF000421, 뒷길이=뒷길이).석공"
|
||
|
||
|
||
# ── ② 단위 검사 ───────────────────────────────────────────────────────
|
||
def test_단위가_어긋난_줄과_자리를_짚는다() -> None:
|
||
got = md.read(BAD_UNIT, BAD_UNIT_정함)
|
||
bad = [x for x in got["문제"] if x["갈래"] == "단위"]
|
||
assert len(bad) == 1 and bad[0]["줄"] == 2
|
||
assert BAD_UNIT.split("\n")[1][bad[0]["자리"]] == "+" # 짚은 자리가 그 셈 기호
|
||
assert "m + ㎡" in bad[0]["말"]
|
||
|
||
|
||
def test_곱셈은_단위를_곱하고_나눗셈은_나눈다() -> None:
|
||
정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "폭": {"무엇": "설계입력", "단위": "m"}}
|
||
assert _var(md.read("넓이 = 길이 * 폭", 정함), "넓이")["단위"] == "㎡"
|
||
부피 = {**정함, "높이": {"무엇": "설계입력", "단위": "m"}}
|
||
assert _var(md.read("부피 = 길이 * 폭 * 높이", 부피), "부피")["단위"] == "㎥"
|
||
나눔 = {
|
||
"부피": {"무엇": "설계입력", "단위": "㎥"},
|
||
"시간당": {"무엇": "설계입력", "단위": "㎥/hr"},
|
||
}
|
||
assert _var(md.read("시간 = 부피 / 시간당", 나눔), "시간")["단위"] == "hr"
|
||
assert _var(md.read(GOOD, GOOD_정함), "공사비")["단위"] == "원" # 인 × 원/인 = 원
|
||
|
||
|
||
def test_만약과_최소는_갈래마다_단위가_같아야_한다() -> None:
|
||
정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "넓이": {"무엇": "설계입력", "단위": "㎡"}}
|
||
got = md.read("값 = 만약(길이 > 3, 길이, 넓이)", 정함)
|
||
assert [x["갈래"] for x in got["문제"]] == ["단위"] and "만약" in got["문제"][0]["말"]
|
||
assert md.read("값 = 최소(길이, 넓이)", 정함)["문제"][0]["갈래"] == "단위"
|
||
assert md.read("값 = 최소(길이, 길이 * 2)", 정함)["문제"] == []
|
||
|
||
|
||
def test_단위_없음을_골라_맨_수로_다룬다() -> None:
|
||
"""횟수 · 배율 — 단위 칸에 「없음」 을 고르면 무차원(3-3 화면 흠 ①)."""
|
||
회 = {"금액": {"무엇": "설계입력", "단위": "원"}, "전용": {"무엇": "설계입력", "단위": "회"}}
|
||
got = md.read("몫 = 금액 / 전용\n계금액 = 몫 + 금액", 회)
|
||
assert _var(got, "몫")["단위"] == "원/회" # 「회」 로 두면 덧셈에서 걸림
|
||
assert [x["갈래"] for x in got["문제"]] == ["단위"]
|
||
|
||
맨수 = dict(회, 전용={"무엇": "설계입력", "단위": "없음"})
|
||
깨끗 = md.read("몫 = 금액 / 전용\n계금액 = 몫 + 금액", 맨수)
|
||
assert _var(깨끗, "몫")["단위"] == "원" and 깨끗["문제"] == []
|
||
assert _var(깨끗, "전용")["단위"] == "" and _var(깨끗, "전용")["정할것"] == []
|
||
for 글 in ("", "맨수", "-", "%"):
|
||
쓴 = dict(회, 전용={"무엇": "설계입력", "단위": 글})
|
||
assert md.read("몫 = 금액 / 전용\n계금액 = 몫 + 금액", 쓴)["문제"] == [], 글
|
||
없으면 = dict(회, 전용={"무엇": "설계입력"}) # 단위 칸 자체가 없으면 아직 안 정한 것
|
||
assert _var(md.read("몫 = 금액 / 전용", 없으면), "전용")["정할것"] == ["단위"]
|
||
|
||
|
||
def test_나눗셈으로_단위가_없어진다() -> None:
|
||
정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "넓이": {"무엇": "설계입력", "단위": "㎡"}}
|
||
assert _var(md.read("폭 = 넓이 / 길이", 정함), "폭")["단위"] == "m"
|
||
assert _var(md.read("비 = 길이 / 길이", 정함), "비")["단위"] == "" # 같은 단위끼리 나누면 맨 수
|
||
assert md.read("합 = 길이 / 길이 + 2", 정함)["문제"] == []
|
||
|
||
|
||
# ── ③ 그 밖 검사 ──────────────────────────────────────────────────────
|
||
def test_서로_물고_도는_식을_잡는다() -> None:
|
||
kinds = [x["갈래"] for x in md.read(LOOP)["문제"]]
|
||
assert "돌고돎" in kinds
|
||
말 = next(x["말"] for x in md.read(LOOP)["문제"] if x["갈래"] == "돌고돎")
|
||
assert "가" in 말 and "나" in 말
|
||
assert md.read("가 = 가 + 1\n끝 = 가")["문제"][0]["갈래"] == "돌고돎"
|
||
|
||
|
||
def test_두_번_정의_안_쓰임_결과_하나() -> None:
|
||
두번 = md.read("가 = 1\n가 = 2\n끝 = 가")["문제"]
|
||
assert 두번[0]["갈래"] == "두번정의" and 두번[0]["줄"] == 2
|
||
안씀 = md.read("가 = 1\n남 = 5\n끝 = 가")["문제"]
|
||
assert [x["갈래"] for x in 안씀] == ["안쓰임"] and 안씀[0]["줄"] == 2
|
||
assert "결과는 「끝」 하나" in 안씀[0]["말"]
|
||
assert md.read("노무비 = 5\n끝 = 노무비")["문제"][0]["갈래"] == "이름"
|
||
assert md.read("이건 식이 아님")["문제"][0]["갈래"] == "못읽음"
|
||
|
||
|
||
def test_식이_왼쪽에_와도_받는다() -> None:
|
||
"""「A * B = C」 로 적는 사용자도 있음(3-3 화면 흠 ②)."""
|
||
정함 = {"길이": {"무엇": "설계입력", "단위": "m"}, "폭": {"무엇": "설계입력", "단위": "m"}}
|
||
got = md.read("길이 * 폭 = 넓이", 정함)
|
||
assert got["문제"] == [] and got["결과"] == "넓이"
|
||
assert _var(got, "넓이")["갈래"] == "정의됨" and _var(got, "넓이")["단위"] == "㎡"
|
||
assert got["줄"][0]["식"] == "길이 * 폭"
|
||
assert md.read("넓이 = 길이 * 폭", 정함)["변수"] == got["변수"] # 어느 쪽으로 적든 같음
|
||
|
||
양쪽 = md.read("길이 * 폭 = 넓이 * 2", 정함)["문제"]
|
||
assert 양쪽[0]["갈래"] == "못읽음" and "양쪽이 다 식" in 양쪽[0]["말"]
|
||
assert 양쪽[0]["자리"] == "길이 * 폭 = 넓이 * 2".index("=")
|
||
assert md.read("길이 * 폭 = 4", 정함)["문제"][0]["갈래"] == "못읽음" # 맨 수는 이름이 아님
|
||
|
||
|
||
def test_비교_기호와_찾기_안_등호는_안_가른다() -> None:
|
||
정함 = {"높이": {"무엇": "설계입력", "단위": "m"}}
|
||
assert md.read("값 = 만약(높이 <= 3, 1, 2)", 정함)["문제"] == []
|
||
assert md.read("값 = 만약(높이 == 3, 1, 2)", 정함)["문제"] == []
|
||
안쪽 = md.read("품 = 찾기(QF000421, 뒷길이=높이).석공", 정함)
|
||
assert 안쪽["문제"] == [] and 안쪽["줄"][0]["이름"] == "품"
|
||
|
||
|
||
# ── ⑤ 로직 틀로 옮기기 ────────────────────────────────────────────────
|
||
def test_다_정하면_로직_틀로_옮긴다() -> None:
|
||
row, bad = md.옮기기(GOOD, GOOD_정함, GOOD_머리)
|
||
assert bad == []
|
||
assert [x["이름"] for x in row["입력"]] == ["길이", "폭"]
|
||
assert row["입력"][0]["설명"] == "쌓을 길이" and row["입력"][0]["단위"] == "m"
|
||
assert [x["이름"] for x in row["중간"]] == ["품", "넓이", "인부"] # 쓰는 차례대로
|
||
assert row["중간"][0]["식"] == "0.15" and row["중간"][1]["식"] == "길이 * 폭"
|
||
assert row["호표"] == [
|
||
{
|
||
"종류": "인력",
|
||
"요소": LABOR,
|
||
"이름": "보통인부",
|
||
"단위": "인",
|
||
"수량": "인부",
|
||
"비목": "노무비",
|
||
}
|
||
]
|
||
assert row["덧줄"] == [{"이름": "잡재료비", "식": "노무비 * 0.05", "비목": "재료비"}]
|
||
assert row["결과단위"] == "원/㎡" and "결과" not in row
|
||
|
||
검사 = store.cm.mf.check_logic(store.cm.master(REAL), {**row, "키": "GX000001"})[0]
|
||
assert 검사 == [] # 옮긴 줄이 지금 로직 검사를 그대로 통과
|
||
|
||
|
||
def test_돈_아닌_로직은_호표_없이_결과_식() -> None:
|
||
정함 = {
|
||
"부피": {"무엇": "설계입력", "단위": "㎥"},
|
||
"시간당": {"무엇": "설계입력", "단위": "㎥/hr"},
|
||
}
|
||
row, bad = md.옮기기("시간 = 부피 / 시간당", 정함, {"이름": "작업 시간", "결과단위": "hr"})
|
||
assert bad == [] and row["결과"] == {"식": "부피 / 시간당"} and row["중간"] == []
|
||
assert "호표" not in row
|
||
|
||
|
||
def test_덜_정했으면_안_옮긴다() -> None:
|
||
row, bad = md.옮기기(GOOD, {}, GOOD_머리)
|
||
assert row == {} and any(x["갈래"] == "덜정함" for x in bad)
|
||
|
||
|
||
def test_비목_안_고른_덧줄은_막지_않고_짚어_준다() -> None:
|
||
"""「못 옮김」 으로 막지 않고 그 값에 「비목」 을 정할 것으로 얹음(3-3 화면 흠 ③)."""
|
||
빼기 = {k: v for k, v in GOOD_정함.items() if k != "잡재료비"}
|
||
got = md.read(GOOD, 빼기, GOOD_머리)
|
||
assert _var(got, "잡재료비")["정할것"] == ["비목"] and got["다됨"] is False
|
||
assert [x["갈래"] for x in got["문제"]] == [] # 검사에는 안 걸림 — 고를 것이 남았을 뿐
|
||
|
||
row, bad = md.옮기기(GOOD, 빼기, GOOD_머리)
|
||
assert row == {} and [x["갈래"] for x in bad] == ["덜정함"]
|
||
assert "잡재료비" in bad[0]["말"] and "비목" in bad[0]["말"]
|
||
assert _var(md.read(GOOD, GOOD_정함, GOOD_머리), "잡재료비")["정할것"] == []
|
||
|
||
안돈 = md.read(GOOD, 빼기, {"이름": "수량만", "결과단위": "㎥"}) # 돈 아닌 로직은 덧줄이 없음
|
||
assert _var(안돈, "잡재료비")["정할것"] == []
|
||
|
||
|
||
# ── 서버 길 ───────────────────────────────────────────────────────────
|
||
def test_서버_길이_변수와_문제를_돌려_준다(client: TestClient) -> None:
|
||
got = _post(client, {"text": GOOD, "decided": GOOD_정함})
|
||
assert got["다됨"] is True and got["logic"] is None and got["결과"] == "공사비"
|
||
assert _var(got, "보통인부")["단위"] == "원/인" # 마스터요소는 식에서 단가
|
||
|
||
미리 = _post(client, {"text": GOOD, "decided": GOOD_정함, "head": GOOD_머리})
|
||
assert 미리["logic"]["호표"][0]["요소"] == LABOR
|
||
assert not (store.FOLDER / make.OWN).is_file() # 미리보기는 파일에 안 씀
|
||
|
||
틀림 = _post(client, {"text": BAD_UNIT, "decided": BAD_UNIT_정함})
|
||
assert any(x["갈래"] == "단위" for x in 틀림["문제"]) and 틀림["다됨"] is False
|
||
|
||
|
||
def test_서버_길이_저장까지_하고_그_로직이_돈다(client: TestClient) -> None:
|
||
done = _post(
|
||
client,
|
||
{"text": GOOD, "decided": GOOD_정함, "head": GOOD_머리, "save": True, "owner": "현장"},
|
||
)
|
||
assert done["key"].startswith("GX") and done["file"] == make.OWN
|
||
assert done["logic"]["구분"] == "자체" and done["logic"]["소유"] == "현장"
|
||
|
||
res = client.post("/api/m01/calc", json={"key": done["key"], "inputs": {"길이": 2, "폭": 3}})
|
||
assert res.status_code == 200, res.text
|
||
calc = res.json()
|
||
assert calc["ok"] is True, calc
|
||
노무 = Decimal(str(calc["sums"]["노무비"]))
|
||
assert 노무 > 0 and Decimal(str(calc["sums"]["재료비"])) == (노무 * Decimal("0.05"))
|
||
assert Decimal(str(calc["middle"]["넓이"])) == 6
|
||
|
||
|
||
def test_저장은_문제가_있으면_아무것도_안_쓴다(client: TestClient) -> None:
|
||
res = client.post(
|
||
"/api/m01/logic/draft",
|
||
json={"text": LOOP, "decided": {}, "head": GOOD_머리, "save": True},
|
||
)
|
||
assert res.status_code == 422, res.text
|
||
assert res.json()["detail"]["errors"]
|
||
assert not (store.FOLDER / make.OWN).is_file()
|