- 표지 = 수식 문서 머리 · 옛 로직 줄 칸 「부품」 — 기계경비식 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>
368 lines
17 KiB
Python
368 lines
17 KiB
Python
"""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
|