Files
Aislo/resources/tester/test_836_1_machine_cover.py
T
eomsangdonandClaude Opus 5.5 c261fee9b7 feat(836-1): 기계 덮음 꼴 · 계산기 짝 규칙 · 서버 저장 정리 — SK 수식 문서 칸 기계덮음
- 자리 = SK 수식.문서.기계덮음 {기계 입력 글: {값 · 조종원 줄 · 잡품 줄}} · 사본 줄 그대로
- 계산기(파이썬 · TS 짝)가 기계 식 부를 때 그 기계 덮음을 얹음 · 덮음 없음 = 문서 그대로
- 서버 저장 · 미리 셈 = 몸 검사 + 원본과 같은 칸 · 안 부르는 기계 뺌
- 거울 · 손셈(굴착기 1.0㎥ 108,958) · 저장 길 지킴 시험 test_836_1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:30:57 +09:00

337 lines
15 KiB
Python

"""836-1 기계 덮음 — 셈 규칙 · 기본값 · 정리 · 몸 검사 · 거울(파이썬 ↔ TS) 지킴 시험.
짝 = `resources/master_data/scripts/master_doc_cover.py` ↔ `common_util/common_util_formula_doc_cover.ts`
(계산기 부름 자리 = `master_doc_calc` ↔ `common_util_formula_doc_calc.ts`).
① 덮음 없음 = 문서 그대로(같은 물건 · 같은 금액) · 남의 기계 덮음 = 안 얹음.
② 굴착기(무한궤도) 1.0㎥(0201-0100 · GC000267) — 값 · 조종원 · 잡품 덮음 = 손셈(M01 값).
③ 조종원 없는 식에 조종원 더함(시간환산 표 더함) · 잡품 없는 식에 잡품 더함 · 연료 + 전력 식 기본 잡품 둘.
④ 정리 — 원본과 같으면 지움 · 안 부르는 기계 지움 · 몸 검사 틀림.
⑤ 거울 — 같은 연결 값으로 파이썬 · TS 답 · 얹은 문서 · 기본값 · 정리가 같음.
"""
import importlib
import json
import os
import subprocess
from decimal import Decimal as D
import pytest
from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠
mc = importlib.import_module("master_doc_calc")
ml = importlib.import_module("master_doc_link")
mcv = importlib.import_module("master_doc_cover")
HERE = os.path.dirname(os.path.abspath(__file__))
COSTS = ("노무비", "재료비", "경비", "계")
NO = "0201-0100" # 굴착기(무한궤도) 1.0㎥ — 조사 846 1 장 견본
@pytest.fixture(scope="module")
def links():
_, whole = m01.loaded()
return ml.Links(whole)
def _one(logic: str, no: str, cover: dict | None = None) -> dict:
"""기계 한 줄 문서(1 hr) — 덮음 = 문서 칸."""
var = {"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "기계", "단위": "",
"연결": {"갈래": "단가산출", "키": logic, "입력": {"기계": f"'{no}'"}}} # fmt: skip
line = {"이름": "기계", "단위": "hr", "비목": "묶음", "수량": "1", "단가": "P_기계"}
doc = {"꼴": "수식문서", "판": 1, "키": "_t", "이름": "t", "결과단위": "원",
"변수": [var], "식": [], "금액": [line]} # fmt: skip
return {**doc, mcv.COVER: cover} if cover else doc
def _money(ans):
return {k: ans.get(k) for k in COSTS}
def _first(links, logic: str) -> str:
var = next(v for v in links.doc(logic)["변수"] if v.get("열쇠") == "기계")
return str(var["연결"]["목록"][0])
def _orig(links, logic: str, no: str) -> dict:
memo: dict = {}
mc.calc(_one(logic, no), links, None, (), memo)
return next(a for (k, _), a in memo.items() if k == logic)
# ── ① 덮음 없음 ────────────────────────────────────────────────────────
def test_덮음_없음_같은_물건_같은_금액(links):
doc = links.doc("GC000267")
assert mcv.cover_doc(doc, {"기계": NO}, {}) is doc
assert mcv.cover_doc(doc, {"기계": NO}, {"0201-0120": {"값": {"가격": 1}}}) is doc
assert mcv.cover_doc(doc, {}, {NO: {"값": {"가격": 1}}}) is doc
base = mc.calc(_one("GC000267", NO), links)
other = mc.calc(_one("GC000267", NO, {"0201-0120": {"값": {"가격": 1}}}), links)
assert _money(base) == _money(other)
assert base["계"] == D(108958) or base["셈됨"] # M01 값이 바뀌면 금액은 달라도 됨(값 대조는 ②)
# ── ② 손셈 ────────────────────────────────────────────────────────────
def _cut1(x):
return D(str(x)).quantize(D("0.1"), rounding="ROUND_DOWN")
def test_굴착기_덮음_손셈(links):
v = _orig(links, "GC000267", NO)["값"]
price, fuel, oil = v["P_기계"], v["P_기계주연료"], v["P_자동차용경유"]
wage, rate = v["w_기계운전사"], v["r_기계잡재료"]
k = (v["k_기계상각비"] + v["k_기계정비비"]) * (1 + v["r_가산"] / 100) + v["k_기계관리비"]
def loss(p):
return (D(p) * 1000 * k / D(10) ** 7).to_integral_value(rounding="ROUND_DOWN")
def total(labor, mats, exp):
return {"노무비": int(labor), "재료비": int(mats), "경비": int(exp)}
fuel_amt = _cut1(fuel * oil)
crew1 = _cut1(D(1) / 8 * wage)
misc = _cut1(fuel_amt * rate / 100)
want = total(crew1, fuel_amt + misc, loss(price))
got = mc.calc(_one("GC000267", NO), links)
assert {c: int(got[c]) for c in ("노무비", "재료비", "경비")} == want
def run(cover):
a = mc.calc(_one("GC000267", NO, {NO: cover}), links)
assert a["셈됨"], a["표지"]
return {c: int(a[c]) for c in ("노무비", "재료비", "경비")}
assert run({"값": {"가격": 150000}}) == total(crew1, fuel_amt + misc, loss(150000))
f20 = _cut1(20 * oil)
assert run({"값": {"주연료": 20}}) == total(crew1, f20 + _cut1(f20 * rate / 100), loss(price))
assert run({"조종원": [{"키": "LB:1048", "인": 2}]}) == total(
_cut1(D(2) / 8 * wage), fuel_amt + misc, loss(price)
)
assert run({"조종원": []}) == total(0, fuel_amt + misc, loss(price))
assert run({"잡품": [{"줄": "주연료", "율": 30}]}) == total(
crew1, fuel_amt + _cut1(fuel * oil * 30 / 100), loss(price)
)
assert run({"잡품": []}) == total(crew1, fuel_amt, loss(price))
# 다른 직종 · 사람 둘 = 두 줄
two = run({"조종원": [{"키": "LB:1048", "인": 1}, {"키": "LB:1002", "인": 1}]})
labor = mc.calc(_one("GC000267", NO, {NO: {"조종원": [{"키": "LB:1002", "인": 1}]}}), links)
assert two["노무비"] == int(crew1) + int(labor["노무비"])
# ── ③ 줄 더함 ─────────────────────────────────────────────────────────
def test_조종원_없는_식에_조종원_잡품_없는_식에_잡품(links):
no = _first(links, "GC000272")
base = mc.calc(_one("GC000272", no), links)
got = mc.calc(_one("GC000272", no, {no: {"조종원": [{"키": "LB:1048", "인": 1}]}}), links)
assert got["셈됨"], got["표지"]
assert got["노무비"] > 0 and got["재료비"] == base["재료비"] and got["경비"] == base["경비"]
no3 = _first(links, "GC000273")
base3 = mc.calc(_one("GC000273", no3), links)
got3 = mc.calc(
_one("GC000273", no3, {no3: {"잡품": [{"키": "LB:1048", "수량": 1, "율": 10}]}}), links
)
assert got3["셈됨"] and got3["재료비"] > base3["재료비"] == 0
def test_연료_전력_식_기본_잡품_둘(links):
doc = links.doc("GC001200")
parts = mcv.parts_of(doc)
assert parts["misc"] is not None and len(parts["base"]) == 2
# ── ④ 정리 · 검사 ─────────────────────────────────────────────────────
def test_원본과_같으면_지움_안_부르는_기계_지움(links):
v = _orig(links, "GC000267", NO)["값"]
same = {NO: {"값": {"가격": v["P_기계"]}, "조종원": [{"키": "LB:1048", "인": 1}],
"잡품": [{"줄": "주연료", "율": v["r_기계잡재료"]}]}} # fmt: skip
assert mcv.COVER not in mcv.prune_doc(_one("GC000267", NO, same), links)
mixed = {NO: {"값": {"가격": v["P_기계"], "주연료": 20}}, "9999-9999": {"값": {"가격": 1}}}
got = mcv.prune_doc(_one("GC000267", NO, mixed), links)[mcv.COVER]
assert got == {NO: {"값": {"주연료": 20}}}
@pytest.mark.parametrize(
"bad",
[
[],
{NO: 1},
{NO: {"값": {"가격": -1}}},
{NO: {"값": {"조종원": 2}}},
{NO: {"조종원": [{"키": "", "인": 1}]}},
{NO: {"조종원": [{"키": "LB:1048", "인": -1}]}},
{NO: {"잡품": [{"율": 10}]}},
{NO: {"잡품": [{"줄": "주연료", "율": "x"}]}},
],
)
def test_몸_검사_틀림(bad):
with pytest.raises(ValueError):
mcv.clean(bad)
def test_몸_검사_빈_칸_뺌():
assert mcv.clean({NO: {"값": {"가격": ""}}}) == {}
assert mcv.clean({NO: {"값": {"가격": 0}}}) == {NO: {"값": {"가격": 0}}}
# ── ⑤ 거울 ────────────────────────────────────────────────────────────
def _canon(v):
if isinstance(v, (D, int, float)) and not isinstance(v, bool):
return mc.plain_text(D(str(v)))
if isinstance(v, list):
return [_canon(x) for x in v]
if isinstance(v, dict):
return {k: _canon(v[k]) for k in sorted(v)}
return v
class Rec:
def __init__(self, links):
self.links, self.log = links, {}
def _keep(self, kind, p, fn):
key = f"{kind}|{json.dumps(_canon(p), ensure_ascii=False, separators=(',', ':'))}"
try:
got = fn(p)
except mc.Gap as e:
self.log[key] = {"빈": str(e)}
raise
except mc.Broken as e:
self.log[key] = {"끊김": str(e)}
raise
self.log[key] = {"값": got}
return got
def element(self, p):
return self._keep("요소", p, self.links.element)
def table(self, p):
return self._keep("표", p, self.links.table)
def doc(self, key):
return self.links.doc(key)
def _same(a, b, where=""):
if isinstance(a, dict):
assert isinstance(b, dict) and set(a) == set(b), (where, sorted(a), sorted(b or {}))
for k in a:
_same(a[k], b[k], f"{where}.{k}")
elif isinstance(a, list):
assert isinstance(b, list) and len(a) == len(b), where
for i, (x, y) in enumerate(zip(a, b)):
_same(x, y, f"{where}[{i}]")
elif isinstance(a, (D, int, float)) and not isinstance(a, bool):
assert isinstance(b, (int, float)) and not isinstance(b, bool), (where, a, b)
assert float(a) == pytest.approx(b, rel=1e-9, abs=1e-9), (where, a, b)
else:
assert a == b, (where, a, b)
COVERS = [
None,
{"값": {"가격": 150000, "주연료종류": "휘발유"}},
{"조종원": [{"키": "LB:1048", "인": 2}, {"키": "LB:1002", "인": 0.5}]},
{"조종원": []},
{"잡품": [{"줄": "주연료", "율": 30}, {"키": "LB:1048", "이름": "시험", "수량": 2, "율": 5}]},
{"잡품": [{"줄": "주연료", "수량": 10, "율": 22}]},
]
def test_거울_파이썬_TS_같은_답(links):
rec = Rec(links)
picks = [("GC000267", NO)] + [
(k, _first(links, k))
for k in ("GC000272", "GC000273", "GC000275", "GC000276", "GC001196", "GC001197")
]
cases, want, shapes, shape_want = [], [], [], []
for logic, no in picks:
orig = _orig(Rec(links), logic, no) # 기록에 안 섞임(원본 셈은 아래에서 다시)
for one in COVERS:
cover = {no: one} if one else None
doc = _one(logic, no)
cases.append({"문서": doc, "덮음": cover})
want.append(mc.calc({**doc, mcv.COVER: cover} if cover else doc, rec))
if cover:
base = mcv.defaults_of(links.doc(logic), orig["값"])
shapes.append({"문서": links.doc(logic), "입력": {"기계": no}, "덮음": cover,
"값": orig["값"]}) # fmt: skip
shape_want.append({"얹음": mcv.cover_doc(links.doc(logic), {"기계": no}, cover),
"기본": base, "정리": mcv.prune(cover[no], base)}) # fmt: skip
docs = {k: links.doc(k) for k, _ in picks}
payload = {"문서들": docs, "대상": cases, "기록": rec.log, "짜임": shapes}
proc = subprocess.run(
["node", os.path.join(HERE, "helper_836_1_machine_cover.cjs")],
input=json.dumps(ml._norm(payload), ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
got = json.loads(proc.stdout)
for i, (a, b) in enumerate(zip(want, got["답"])):
_same(ml._norm(_money(a)), _money(b), f"답{i}")
_same(ml._norm(a["값"]), b["값"], f"값{i}")
assert len(got["짜임"]) == len(shape_want)
for i, (a, b) in enumerate(zip(shape_want, got["짜임"])):
_same(ml._norm(a), b, f"짜임{i}")
# ── ⑥ 서버 저장 길(임시 층 · 수식 길) ────────────────────────────────────
@pytest.fixture
def world(tmp_path, monkeypatch):
from resources.tester.test_m02_labor_ref import world as make
return make.__wrapped__(tmp_path, monkeypatch)
def test_저장_다시_열기_되돌리면_덮음_없음(world):
import copy as cp
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
me = world["personal"]
def at(fn, *args):
with store.at(me):
return fn(*args)
key = at(
lambda: refs.create("sangeun", {"이름": "sub3임시 덮음", "단위": "시간", "더한줄": []})
)
key = key["이름"]
got = at(path.read, key)
doc = cp.deepcopy(got["문서"])
link = {"갈래": "단가산출", "키": "GC000267", "입력": {"기계": f"'{NO}'"}}
for s in ("기계", "기계둘"):
doc.setdefault("변수", []).append(
{"기호": "P", "아래": s, "위": "", "이름": s, "단위": "", "연결": link}
)
doc.setdefault("금액", []).append(
{"이름": s, "단위": "hr", "비목": "묶음", "수량": "1", "단가": f"P_{s}"}
)
at(path.save, key, {"문서": doc, "판": got["판"]})
base = at(path.read, key)
was = base["셈"]["계"]
one = base["셈"]["줄"][0]["금액"]
doc = cp.deepcopy(base["문서"])
doc[mcv.COVER] = {NO: {"값": {"가격": 150000}}, "9999-9999": {"값": {"가격": 1}}}
after = at(path.save, key, {"문서": doc, "판": base["판"]})
assert after["문서"][mcv.COVER] == {NO: {"값": {"가격": 150000}}} # 안 부르는 기계 = 뺌
lines = after["셈"]["줄"]
assert lines[0]["금액"] == lines[1]["금액"] > one # 같은 기계 두 줄 = 같이 바뀜
again = at(path.read, key)
assert again["문서"][mcv.COVER] == {NO: {"값": {"가격": 150000}}}
assert again["셈"]["계"] == after["셈"]["계"] > was
v = _orig(ml.Links(m01.loaded()[1]), "GC000267", NO)["값"]
doc = cp.deepcopy(again["문서"])
doc[mcv.COVER] = {NO: {"값": {"가격": v["P_기계"]}}} # 되돌림 = 원본 값
back = at(path.save, key, {"문서": doc, "판": again["판"]})
assert mcv.COVER not in back["문서"] and back["셈"]["계"] == was
with store.at(me):
file = store.read("sangeun", key)["문서"]
assert mcv.COVER not in file["수식"]["문서"]
doc[mcv.COVER] = {NO: {"값": {"가격": -5}}}
with pytest.raises(store.StoreError):
at(path.save, key, {"문서": doc, "판": back["판"]})