Files
Aislo/resources/tester/test_m01_draft.py
T
eomsangdonandClaude Opus 5 8a91fa3cca fix(M01): 식 해석기 흠 셋 — 단위 없는 맨 수 · 「식 = 이름」 꼴 · 비목 안 고른 덧줄
· 단위 칸에 「없음」(맨수 · - · % · 빈 글)을 골라 무차원으로 둘 수 있게 함 —
  횟수·배율을 「회」 로 두어 「원/회」 가 남던 덧셈 검사 걸림을 풂 ·
  같은 단위끼리 나누면 맨 수가 되는 것도 맞음
· 줄을 「이름 = 식」 과 「식 = 이름」(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
2026-09-21 20:57:43 +09:00

298 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""식으로 새 로직 만들기 — 변수 뽑기 · 검사 · 단위 · 로직 틀로 옮기기 (`_화면_계약.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()