"""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