Files
Aislo/resources/tester/test_796_1_formula_model.py
T
eomsangdonandClaude Opus 5.5 58a3e039b3 feat(813-2): 보조 문서 부품 표지 73 · 작업량당 기계경비 6 지움 · 단가산출 목록에서 뺌 · 기계 → 부품 길
- 표지 = 수식 문서 머리 · 옛 로직 줄 칸 「부품」 — 기계경비식 9 · 작업량 틀 60(기종 = 기계표 상세구분 · 규격 열쇠) · 계수식 4 · 변환기가 옮김 · 틀 문서 9 장
- 지움 = 작업량당 기계경비 6(GC001145 · 1148 · 1151 · 1156 · 1159 · 1166 · 부르는 곳 0 · 요구 734-4 · 735-4) — 수식 문서 · 옛 로직 · 키대장 폐기 칸 · 빌더 · 변수표
- 이름 꼬리 4 = 원문 제목(지게운반 · 인력운반(기계설비) · 벽돌운반 · 수중펌프 설치 및 해체) · 키 폐기 0
- 서버 = `M01_MasterData_Parts` — M01 `/logics` · M02 `/logic-lib/logics` 목록에서 부품 뺌(1,440) · 키로 열기 · 부르기 그대로 · `/m01/parts`(기종 계산식 · 틀 · 계수식) · `/m02/logic-lib/machine-parts` · 연결 찾기 과녁에서 부품 뺌(1,444 → 1,435 · 판정 코드 그대로)
- 금액 전후 = 문서 1,513 · 셈 9,626(기본 + 옵션 표본) 모두 같음 · 791_1 지문 남은 1,513 같음 → 지문 다시 뜸 · 802_2 변환 대조 1,513 · 목록 xlsx 다시 씀(요약 수)
- 낡은 시험 = 1,519 · 1,518 기대 → 1,513 · GC001148 견본 → 같은 꼴 GF000089 토사깍기(기계)(블록 Q 39.293 · 합계 2,190) · 지운 6 을 셈하던 줄 걷음 · 목록 수 1,440 · 묶음 328 · 부르는 수 507
- 새 지킴 시험 test_813_2_parts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:49:52 +09:00

368 lines
17 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.
"""796-1 수식 모델 지킴 — 엔진 로직 ↔ 수식 나무 양방향 · 변수 표 · 추가 · 삭제 · 이름 전파 · 검사.
① 예시 셋(요구서_655 · `_분석_655-B` 2 장) — GF000089 토사깍기(블록 GF000088 Q = 39.293 · GC000267 · 813-2 GC001148 지움 뒤) ·
GC001140 지게(N · M · 42,917) · GC000211 거푸집(시공량 45 · 형틀목공 5/45 · 공구손료 1 %).
② 왕복 — M01 로직 식 전부: 엔진 식 → 나무 → 엔진 식 = 같은 식 나무 · 예시 셋 = 같은 금액.
③ 고침 — 식 고침 = 금액 바뀜 · 변수 더함 · 지움(쓰는 곳 막음) · 이름 · 기호 · 첨자 전파(식 그대로) ·
표시 자리 셈(고친 줄만) · 검사 까닭(없는 이름 · 괄호 · 순환 · 0 나눔 · 상한).
④ M01 길 — 읽기 · 미리 셈 · 저장(개정확인 · 임시 사본 폴더 · 정본 안 건드림).
변수표 칸 셈 값 바뀜 0 · M01 1,519 지문은 `test_791_1_engine_semantics.py`.
"""
from __future__ import annotations
import copy
import importlib
import json
import shutil
from decimal import Decimal as D
from functools import lru_cache
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Equation as eq_m01
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
mf = importlib.import_module("master_formula")
mcut = importlib.import_module("master_cut")
meq = importlib.import_module("master_equation")
me = importlib.import_module("master_equation_edit")
revise = importlib.import_module("M01_MasterData.M01_MasterData_Store_Revise")
REAL = store.FOLDER
EXCAVATOR, AFRAME, FORM = "GF000089", "GC001140", "GC000211"
GIVEN = {AFRAME: {"운반회수": 0}, FORM: {"유형": "보통", "높이감률": 0}, EXCAVATOR: {}}
@lru_cache(maxsize=1)
def whole():
return store.loaded()[1]
def export(key: str, row: dict | None = None) -> dict:
w = whole()
return meq.export(me.swap(w, row) if row else w, key, GIVEN[key])
def line(eq: dict, key: str, kind: str = "중간") -> dict:
return next(x for x in eq["줄"] if x["열쇠"] == key and x["갈래"] == kind)
def raw(key: str, row: dict | None = None) -> dict:
w = me.swap(whole(), row) if row else whole()
r = row or w.logic(key)
return mf.run(w, key, meq.given_of(w, r, GIVEN[key]))
def vars_in(tree) -> list[dict]:
if isinstance(tree, dict):
own = [tree] if tree.get("꼴") == "변수" else []
return own + [v for x in tree.values() for v in vars_in(x)]
if isinstance(tree, list):
return [v for x in tree for v in vars_in(x)]
return []
def edit(key: str, edits: list) -> tuple[dict, list[str]]:
return eq_m01.edit_row(whole(), whole().logic(key), edits)
# ── ① 예시 셋 ─────────────────────────────────────────────────────────
def test_굴착기_블록_Q_노재경():
eq = export(EXCAVATOR)
block = line(eq, "작업량")["이름식"]
assert block["꼴"] == "블록" and block["열쇠"] == "GF000088" and block["값"] == D("39.293")
sub = block["나무"]
q = line(sub, "결과", "결과")
assert q["결과"]["기호"] == "Q" and q["값"] == D("39.293")
frac = q["이름식"]["인자"][0] # 반올림(분수, 3)
assert frac["꼴"] == "분수" and frac["아래"]["열쇠"] == "Cm"
assert [v["기호"] for v in vars_in(frac["위"])] == ["q", "K", "f", "E"]
nums = [x.get("글") for x in vars_in(q["값식"])] or []
shown = json.dumps(q["값식"], default=str, ensure_ascii=False)
assert all(f'"글": "{n}"' in shown for n in ("3,600", "0.7", "0.9", "0.77", "0.45", "20"))
assert nums == [] # 값 꼴엔 변수 없음
item = line(eq, "굴착기(무한궤도) 0.7㎥", "호표")
price = item["이름식"]["좌"]
assert price["꼴"] == "블록" and price["열쇠"] == "GC000267"
assert set(price["묶음"]) == {"노무비", "재료비", "경비", "계"}
assert item["이름식"]["우"]["꼴"] == "분수" # 1 / 작업량
cut = mcut.engine(whole(), EXCAVATOR, raw(EXCAVATOR))
assert eq["합계"] == {k: cut[k] for k in mf.BUNDLE}
assert eq["합계"]["계"] == D("2190.0")
def test_지게_N_M_42917():
eq = export(AFRAME)
n = line(eq, "일운반회수")
assert n["결과"]["기호"] == "N" and n["글"] == "92.2131"
assert n["이름식"]["꼴"] == "조건" and n["이름식"]["걸림"] == 2 # 운반회수 0 → 식
frac = n["이름식"]["거짓"]
assert frac["꼴"] == "분수"
assert [v["기호"] for v in vars_in(frac["위"])] == ["V", "T"]
m = line(eq, "보통인부", "호표")
qty = m["이름식"]["우"]
assert qty["꼴"] == "분수" and qty.get("사슬") # 무게 / 운반량 / N → 무게 / (운반량 × N)
assert [v["기호"] for v in vars_in(qty["아래"])] == ["Q", "N"]
assert m["수량"].quantize(D("0.0001")) == D("0.2494")
assert eq["합계"]["노무비"] == D("42917.0")
signs = {v["열쇠"]: v["기호"] for v in eq["변수표"]}
assert signs.items() >= {"운반거리": "L", "적재적하시간": "t", "평균왕복속도": "V"}.items()
assert signs["일운반회수"] == "N" and signs["운반회수"] == "n" # 한 기호 = 한 변수
table = next(p for p in eq["풀이"] if p.get("표") == "CC000343")
assert table["행"] == {"종류": "석재류", "운반로": "불량"}
assert {p["갈래"] for p in eq["풀이"]} >= {"입력", "찾기", "요소"}
def test_거푸집_시공량_형틀목공_공구손료():
eq = export(FORM)
s = line(eq, "시공량")
assert s["값"] == 45 and "퍼센트" in json.dumps(s["이름식"], default=str, ensure_ascii=False)
w = line(eq, "형틀목공")
assert w["이름식"]["꼴"] == "분수" and w["글"] == "0.1111"
tool = line(eq, "공구손료 및 경장비", "덧줄")
assert tool["이름식"]["꼴"] == "퍼센트" and tool["글"] == "382.908"
def test_기호_후보_원문():
w = whole()
assert meq.symbols(w.logic("GC001147")) == {"버킷용량": "q", "결과": "Q"}
got = meq.symbols(w.logic(AFRAME))
assert got == {"운반거리": "L", "실작업시간": "T", "적재적하시간": "t", "평균왕복속도": "V"} | {
"일운반회수": "N"
}
# ── ② 왕복 ────────────────────────────────────────────────────────────
def _texts(row: dict):
for step in row.get("중간") or []:
yield step["식"]
if "결과" in row:
yield row["결과"]["식"]
for item in row.get("호표") or []:
yield item["수량"]
if isinstance(item.get("요소"), str):
yield item["요소"]
for extra in row.get("덧줄") or []:
yield extra["식"]
def test_왕복_M01_식_전부_같은_나무():
files, _ = store.loaded()
count, bad = 0, []
for data in files.values():
if data.get("그룹") != "로직":
continue
for row in data.get("줄") or []:
for text in _texts(row):
try:
node = mf.parse(text)
except mf.FormulaError:
continue
back = me.engine_text(meq.build(node, None))
count += 1
if mf.parse(back) != node or mf.parse(me.raw_text(node)) != node:
bad.append((row["키"], text, back))
assert count > 9000 and not bad, bad[:5]
@pytest.mark.parametrize("key", [EXCAVATOR, AFRAME, FORM, "GC001147"])
def test_왕복_예시_같은_금액(key):
GIVEN.setdefault("GC001147", {})
w = whole()
eq = meq.export(w, key, GIVEN[key])
row = copy.deepcopy(w.logic(key))
for one in eq["줄"]:
tree = one["이름식"]["우"] if one["갈래"] == "호표" else one["이름식"]
text = me.engine_text(tree)
if one["갈래"] == "중간":
next(s for s in row["중간"] if s["이름"] == one["열쇠"])["식"] = text
elif one["갈래"] == "결과":
row["결과"]["식"] = text
else:
row[one["갈래"]][one.get("차례", 0)]["수량" if one["갈래"] == "호표" else "식"] = text
before, after = raw(key), raw(key, row)
pick = ("결과",) if "결과" in row else mf.BUNDLE
assert {k: after[k] for k in pick} == {k: before[k] for k in pick}
def test_변수표_칸은_셈_안_바꿈():
row = dict(whole().logic(AFRAME))
row[meq.FIELD] = {"일운반회수": {"기호": "n", "아래": "d", "이름": "하루 운반 횟수"}}
assert raw(AFRAME, row)["계"] == raw(AFRAME)["계"]
assert revise.row_print(row) == revise.row_print(whole().logic(AFRAME)) # M01 개정 배지 안 뜸
# ── ③ 고침 ────────────────────────────────────────────────────────────
def test_식_고침_금액_바뀜_표시_자리():
new, why = edit(AFRAME, [{"할일": "식", "자리": "중간:환산거리", "식": "환산계수 × L × 2"}])
assert not why
step = next(s for s in new["중간"] if s["이름"] == "환산거리")
assert step["식"] == "환산계수 * 운반거리 * 2" and step["표시"] == meq.SHOW
others = [s for s in new["중간"] if s["이름"] != "환산거리"]
assert all("표시" not in s for s in others) # 고친 줄만
eq = export(AFRAME, new)
assert line(eq, "환산거리")["값"] == 96
assert eq["합계"]["노무비"] > export(AFRAME)["합계"]["노무비"]
def test_표시_자리에서_잘라_셈():
old = line(export(AFRAME), "일운반회수")["식"]
new, why = edit(AFRAME, [{"할일": "식", "자리": "중간:일운반회수", "식": old}])
assert not why and new["중간"][-1]["표시"] == 4
got = raw(AFRAME, new)
assert got["중간"]["일운반회수"] == D("92.2131")
assert got["노무비"] == D(575) / D(25) / D("92.2131") * D(172068)
assert got["노무비"] != raw(AFRAME)["노무비"] # 보이는 자리 셈 = 금액 바뀜(요구 658)
def test_변수_더함_입력_중간_식에서_부름():
new, why = edit(
AFRAME,
[
{
"할일": "더함",
"갈래": "입력",
"열쇠": "할증",
"기호": "k",
"아래": "s",
"기본값": "1.1",
},
{"할일": "더함", "갈래": "중간", "열쇠": "할증무게", "식": "무게 × k_s", "뒤": "무게"},
{"할일": "식", "자리": "호표:0", "식": "할증무게 ÷ 운반량 ÷ N"},
],
)
assert not why, why
assert new["입력"][-1] == {"이름": "할증", "기본": D("1.1")}
mids = [s["이름"] for s in new["중간"]]
assert mids[:2] == ["무게", "할증무게"] and new["중간"][1]["식"] == "무게 * 할증"
assert new["호표"][0]["수량"] == "할증무게 / 운반량 / 일운반회수"
eq = export(AFRAME, new)
assert line(eq, "할증무게")["값"] == D("632.5")
base = export(AFRAME)["합계"]["노무비"]
assert abs(eq["합계"]["노무비"] - base * D("1.1")) <= 1
def test_변수_지움_쓰는_곳_막음():
new, why = edit(AFRAME, [{"할일": "지움", "열쇠": "무게"}])
assert why and "호표 1 보통인부 수량" in why[0]
new, why = edit(AFRAME, [{"할일": "지움", "열쇠": "운반회수"}])
assert why and "중간 일운반회수" in why[0]
new, why = edit(
AFRAME,
[
{"할일": "더함", "갈래": "중간", "열쇠": "임시", "식": "무게 × 2"},
{"할일": "지움", "열쇠": "임시"},
],
)
assert not why and "임시" not in {s["이름"] for s in new["중간"]}
assert new["중간"] == whole().logic(AFRAME)["중간"]
def test_이름_기호_첨자_바꿈_전파():
new, why = edit(
AFRAME,
[
{
"할일": "변수",
"열쇠": "일운반회수",
"기호": "n",
"아래": "d",
"이름": "하루 운반 횟수",
}
],
)
assert not why
assert new["호표"] == whole().logic(AFRAME)["호표"] # 식은 열쇠 — 그대로
assert new["중간"] == whole().logic(AFRAME)["중간"]
eq = export(AFRAME, new)
used = [v for v in vars_in(line(eq, "보통인부", "호표")["이름식"]) if v["열쇠"] == "일운반회수"]
assert used and used[0]["기호"] == "n" and used[0]["아래"] == "d"
assert used[0]["이름"] == "하루 운반 횟수"
assert eq["합계"] == export(AFRAME)["합계"]
typed = eq_m01.export_row(whole(), new, GIVEN[AFRAME])
assert "n_d" in line(typed, "보통인부", "호표")["입력글"]
again, why = eq_m01.edit_row(
whole(), new, [{"할일": "식", "자리": "호표:0", "식": "무게 ÷ 운반량 ÷ n_d"}]
)
assert not why and again["호표"][0]["수량"] == "무게 / 운반량 / 일운반회수"
_, why = edit(
AFRAME, [{"할일": "변수", "열쇠": "운반량", "기호": "N", "아래": ""}]
) # 겹침(운반량 기본 아래 · 800-1 을 비움)
assert why and "겹침" in why[0]
def test_검사_까닭():
cases = {
"없는 이름": ("중간:환산거리", "환산계수 × 없는값", "모르는 기호"),
"괄호": ("중간:환산거리", "(환산계수 × L", "없음"),
"아래 줄": ("중간:무게", "일운반회수 × 2", "아래 줄"),
"순환": ("중간:무게", "무게 + 1", "자기 자신"),
"0 나눔": ("중간:환산거리", "환산계수 / 0", "0 으로 나눔"),
"길이": ("중간:환산거리", "1 + " * 80 + "1", "자를 넘음"),
"깊이": ("중간:환산거리", "(" * 13 + "1" + ")" * 13, "겹을 넘음"),
}
for name, (spot, text, want) in cases.items():
_, why = edit(AFRAME, [{"할일": "식", "자리": spot, "식": text}])
assert why and any(want in w for w in why), (name, why)
_, why = edit(AFRAME, [{"할일": "더함", "갈래": "입력", "열쇠": "무게"}])
assert why and "같은 열쇠" in why[0]
_, why = edit(AFRAME, [{"할일": "더함", "갈래": "입력", "열쇠": "LB000001"}])
assert why and "꼴 아님" in why[0]
_, why = edit(AFRAME, [{"할일": "표시", "열쇠": "무게", "표시": 9}])
assert why
# ── ④ M01 길 ──────────────────────────────────────────────────────────
@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 test_M01_길_읽기_미리셈_저장(client: TestClient):
file = "로직_건설품셈_01장_적용기준.json"
real = (REAL / file).read_bytes()
body = {"key": AFRAME, "inputs": {"운반회수": 0}}
got = client.post("/api/m01/logic/equation", json=body).json()
assert got["ok"] and got["수식"]["합계"]["노무비"] == 42917
edits = [
{"할일": "식", "자리": "중간:환산거리", "식": "환산계수 × L × 2"},
{"할일": "변수", "열쇠": "일운반회수", "기호": "n"},
]
pre = client.post("/api/m01/logic/equation/preview", json=body | {"고침": edits}).json()
assert pre["되나"] and pre["후"]["노무비"] > pre["전"]["노무비"]
assert (store.FOLDER / file).read_bytes() == real # 미리 셈 = 쓰기 없음
bad = client.post(
"/api/m01/logic/equation/preview", json=body | {"고침": [edits[0] | {"식": "x)"}]}
)
assert not bad.json()["되나"]
res = client.post("/api/m01/logic/equation/save", json=body | {"고침": edits})
assert res.status_code == 409 # 정본 = 개정 확인
res = client.post("/api/m01/logic/equation/save", json=body | {"고침": edits, "개정확인": True})
assert res.status_code == 200, res.text
assert res.json()["수식"]["합계"]["노무비"] == pre["후"]["노무비"]
saved = json.loads((store.FOLDER / file).read_text(encoding="utf-8"))
before = json.loads(real.decode("utf-8"))
rows = {r["키"]: r for r in saved["줄"]}
seeded = {r["키"]: r for r in before["줄"]}[AFRAME].get(meq.FIELD) or {} # 796-3 기본 기호
assert rows[AFRAME][meq.FIELD] == seeded | {"일운반회수": {"기호": "n"}}
assert {k: v for k, v in rows.items() if k != AFRAME} == {
r["키"]: r for r in before["줄"] if r["키"] != AFRAME
}
assert (REAL / file).read_bytes() == real # 정본 안 건드림
stale = client.post(
"/api/m01/logic/equation/save",
json=body | {"고침": edits, "version": "옛판", "개정확인": True},
)
assert stale.status_code == 409