feat(802-1): 단가산출 수식 문서 꼴 · 계산기(파이썬 · TS 짝) · 표지 · 꼴 문서
- 꼴: 변수표(기호 · 첨자 · 이름 · 단위 · 연결 = 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식) + 식 줄 + 금액 줄 + 끝수 칸 — resources/master_data/_수식문서_틀.md - 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파 · 정적 검사 = master_doc.py ↔ common_util_formula_doc.ts - 셈 = master_doc_calc.py ↔ common_util_formula_doc_calc.ts — 기호는 필요할 때 셈 · 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 = 표지 + 금액 없음 · 값 없음 = 그 줄만 비움 · 묶음 소계 · 합계 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖 - M01 연결 풀이(표 찾기 · 요소 · 단가산출 문서) = master_doc_link.py ↔ common_util_formula_doc_table.ts(표 찾기) · 옛 엔진 셈 안 부름 - 옛 로직 변환 = master_doc_convert.py(802-2 빌더가 부름) - 시험 test_802_1_formula_doc 48(꼴 · 셈 · 지움 견딤 300 넘는 지운 문서 예외 0 · 표지 · 거울 파이썬 ↔ TS 60 문서) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/* 802-1 수식 문서 거울 헬퍼 — common_util_formula_doc*.ts 를 그 자리에서 트랜스파일해 Node 로 돌림.
|
||||
* stdin = {문서들: {키: 문서}, 대상: [키], 기록: {열쇠: {값 | 빈 | 끊김}}, 글: [식 글], 표: [{표, 조건, 칸}]}
|
||||
* stdout = {답: {키: calcDoc 답}, 글: [textOf 또는 {틀림}], 표: [tableValue 값 또는 {빈 | 끊김}]}
|
||||
* 기록 열쇠 = 갈래 + "|" + 값 글(파이썬 `_canon` 과 같은 꼴). */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const cache = new Map();
|
||||
function load(at) {
|
||||
if (cache.has(at)) return cache.get(at);
|
||||
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(at, box.exports);
|
||||
const need = (p) =>
|
||||
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, need);
|
||||
return box.exports;
|
||||
}
|
||||
const root = path.join(__dirname, "..", "..", "common_util");
|
||||
const doc = load(path.join(root, "common_util_formula_doc.ts"));
|
||||
const calc = load(path.join(root, "common_util_formula_doc_calc.ts"));
|
||||
const table = load(path.join(root, "common_util_formula_doc_table.ts"));
|
||||
|
||||
function canon(v) {
|
||||
if (typeof v === "number") return calc.plainText(v);
|
||||
if (Array.isArray(v)) return v.map(canon);
|
||||
if (v && typeof v === "object") {
|
||||
const out = {};
|
||||
for (const k of Object.keys(v).sort()) out[k] = canon(v[k]);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
const keyOf = (kind, p) => `${kind}|${JSON.stringify(canon(p))}`;
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const record = input.기록 || {};
|
||||
function answer(kind, p) {
|
||||
const k = keyOf(kind, p);
|
||||
if (!(k in record)) throw new calc.Broken(`기록 없음 ${k}`);
|
||||
const got = record[k];
|
||||
if ("빈" in got) throw new calc.Gap(got.빈);
|
||||
if ("끊김" in got) throw new calc.Broken(got.끊김);
|
||||
return got.값;
|
||||
}
|
||||
const links = {
|
||||
element: (p) => answer("요소", p),
|
||||
table: (p) => answer("표", p),
|
||||
doc: (key) => (input.문서들 || {})[key] || null,
|
||||
};
|
||||
const out = { 답: {}, 글: [], 표: [] };
|
||||
for (const key of input.대상 || []) out.답[key] = calc.calcDoc(input.문서들[key], links);
|
||||
for (const text of input.글 || []) {
|
||||
try {
|
||||
out.글.push(doc.textOf(doc.parse(text)));
|
||||
} catch (e) {
|
||||
if (!(e instanceof doc.DocError)) throw e;
|
||||
out.글.push({ 틀림: true });
|
||||
}
|
||||
}
|
||||
for (const one of input.표 || []) {
|
||||
try {
|
||||
out.표.push({ 값: table.tableValue(one.표, { 조건: one.조건, 칸: one.칸 }) });
|
||||
} catch (e) {
|
||||
if (e instanceof calc.Gap) out.표.push({ 빈: true });
|
||||
else if (e instanceof calc.Broken) out.표.push({ 끊김: true });
|
||||
else throw e;
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,588 @@
|
||||
"""802-1 단가산출 수식 문서 — 꼴 · 셈 · 지움 견딤 · 표지 · 거울(파이썬 ↔ TS) 지킴 시험.
|
||||
|
||||
꼴 = `resources/master_data/_수식문서_틀.md` · 파이썬 = `master_doc*.py` · TS = `common_util_formula_doc*.ts`.
|
||||
① 식 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파.
|
||||
② 셈 — 입력 · 선택 · 숫자 · 식 · 만약 · % · 표 조건(범위 규칙) · 요소 · 단가산출 부름 · 묶음 소계 · 합계 ·
|
||||
반올림 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖 · 결과 문서.
|
||||
③ 표지 — 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 · 겹침 = 금액 「—」 · 예외 없음.
|
||||
④ 지움 견딤 — 변수 · 식 줄 · 금액 줄 · 식 글 하나씩 모두 지워도 예외 0.
|
||||
⑤ 거울 — 같은 문서 · 같은 연결 값으로 파이썬 · TS 답이 같음(실제 M01 문서 표본 + 위 문서들).
|
||||
"""
|
||||
|
||||
import copy
|
||||
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 길을 둠
|
||||
|
||||
md = importlib.import_module("master_doc")
|
||||
mc = importlib.import_module("master_doc_calc")
|
||||
ml = importlib.import_module("master_doc_link")
|
||||
cv = importlib.import_module("master_doc_convert")
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
COSTS = ("노무비", "재료비", "경비", "계")
|
||||
|
||||
|
||||
# ── 시험 연결 ─────────────────────────────────────────────────────────
|
||||
TABLE = {
|
||||
"키": "QX990001",
|
||||
"원문번호": "9-9-9",
|
||||
"조건": {"유형": "고르기", "두께": "범위"},
|
||||
"범위규칙": "초과·이하",
|
||||
"값칸": {"품": "인", "율": "%"},
|
||||
"줄": [
|
||||
{"유형": "보통", "두께": [None, D(10)], "품": D("0.1"), "율": D(5)},
|
||||
{"유형": "보통", "두께": [D(10), D(20)], "품": D("0.2"), "율": D(6)},
|
||||
{"유형": "복잡", "두께": [None, D(20)], "품": D("0.3")},
|
||||
{"유형": "분수", "품": "1/1.30"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Fake:
|
||||
"""시험 연결 — 요소 {키: 값 | Gap 까닭} · 표 {키: 표} · 문서 {키: 문서}."""
|
||||
|
||||
def __init__(self, elements=None, tables=None, docs=None):
|
||||
self.elements = elements or {}
|
||||
self.tables = {"QX990001": TABLE, **(tables or {})}
|
||||
self.docs = docs or {}
|
||||
|
||||
def element(self, p):
|
||||
key = p.get("키") or json.dumps(p.get("재료"), ensure_ascii=False, sort_keys=True)
|
||||
if key not in self.elements:
|
||||
raise mc.Broken(f"없는 요소 「{key}」")
|
||||
got = self.elements[key]
|
||||
if isinstance(got, str) and got.startswith("빈:"):
|
||||
raise mc.Gap(got[2:])
|
||||
return got[p["칸"]] if p.get("칸") else got
|
||||
|
||||
def table(self, p):
|
||||
if p["표"] not in self.tables:
|
||||
raise mc.Broken(f"없는 표 「{p['표']}」")
|
||||
return ml.Links.table(_TableOnly(self.tables[p["표"]]), p)
|
||||
|
||||
def doc(self, key):
|
||||
return self.docs.get(key)
|
||||
|
||||
|
||||
class _TableOnly:
|
||||
def __init__(self, table):
|
||||
self.t = table
|
||||
|
||||
def _get(self, ref):
|
||||
return self.t
|
||||
|
||||
|
||||
def _var(sid, link, key=None, name=""):
|
||||
sign, low, up = md.split_id(sid)
|
||||
return {
|
||||
"열쇠": key or sid,
|
||||
"기호": sign,
|
||||
"아래": low,
|
||||
"위": up,
|
||||
"이름": name or sid,
|
||||
"연결": link,
|
||||
}
|
||||
|
||||
|
||||
def _doc(variables=(), lines=(), money=(), **more):
|
||||
out = md.blank("GX990001", "시험")
|
||||
out["변수"] = [_var(*v) if isinstance(v, tuple) else v for v in variables]
|
||||
out["식"] = [{"기호": a, "식": b} for a, b in lines]
|
||||
out["금액"] = list(money)
|
||||
out.update(more)
|
||||
return out
|
||||
|
||||
|
||||
def _man(name, qty, price, kind="노무비", **more):
|
||||
return {"이름": name, "단위": "인", "비목": kind, "수량": qty, "단가": price, **more}
|
||||
|
||||
|
||||
def _calc(doc, links=None, given=None):
|
||||
return mc.calc(doc, links or Fake(), given)
|
||||
|
||||
|
||||
def _money(got):
|
||||
return {k: got[k] for k in COSTS}
|
||||
|
||||
|
||||
def SAMPLE():
|
||||
"""합판거푸집 꼴 견본 — 선택 · 입력 · 표(범위) · 요소 · 식 · 만약 · % · 덧줄 꼴 금액."""
|
||||
return _doc(
|
||||
[
|
||||
("S_유형", {"갈래": "선택", "목록": ["보통", "복잡"], "값": "보통"}, "유형"),
|
||||
("t", {"갈래": "입력", "값": D(15), "범위": [0, 100]}, "두께"),
|
||||
(
|
||||
"Q^표",
|
||||
{
|
||||
"갈래": "표",
|
||||
"표": "QX990001",
|
||||
"조건": {"유형": "S_유형", "두께": "t"},
|
||||
"칸": "품",
|
||||
},
|
||||
),
|
||||
("w_형틀", {"갈래": "요소", "키": "LB990001"}),
|
||||
("w_보통", {"갈래": "요소", "키": "LB990002"}),
|
||||
("r_할증", {"갈래": "숫자", "값": D(10)}),
|
||||
("Q_형틀", {"갈래": "식"}),
|
||||
],
|
||||
[("Q_형틀", "Q^표 × (1 + 만약(S_유형 == '복잡', r_할증, 0)%)")],
|
||||
[
|
||||
_man("형틀목공", "Q_형틀", "w_형틀", 기호="C_형틀"),
|
||||
_man("보통인부", "0.05", "w_보통"),
|
||||
{"이름": "공구손료", "단위": "식", "비목": "경비", "단가": "노무비 × 3%"},
|
||||
{"이름": "소모", "단위": "식", "비목": "재료비", "단가": "C_형틀 × 1 ÷ 3"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
LABOR = {"LB990001": D(274978), "LB990002": D(165545)}
|
||||
|
||||
|
||||
# ── ① 꼴 ──────────────────────────────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"text, want",
|
||||
[
|
||||
("a*b/c", "a × b ÷ c"),
|
||||
("a/(b*c)", "a ÷ (b × c)"),
|
||||
("a-(b-c)", "a - (b - c)"),
|
||||
("(a-b)-c", "a - b - c"),
|
||||
("-x*2", "-x × 2"),
|
||||
("-(a+b)", "-(a + b)"),
|
||||
("x %", "x%"),
|
||||
("(a+b)%", "(a + b)%"),
|
||||
("3600×q_s×K×f×E_s÷C_m", "3600 × q_s × K × f × E_s ÷ C_m"),
|
||||
("만약(S=='보통' 그리고 t<=10, 1, 0)", "만약(S == '보통' 그리고 t <= 10, 1, 0)"),
|
||||
("a ≤ b", "a <= b"),
|
||||
("(a<b) == (c<d)", "(a < b) == (c < d)"),
|
||||
("B_굴착기.경비 × Q", "B_굴착기.경비 × Q"),
|
||||
("번지.A01.재료비 × 11 ÷ 100", "번지.A01.재료비 × 11 ÷ 100"),
|
||||
("단계.'설치 소계'.계", "단계.'설치 소계'.계"),
|
||||
("Q_시공^표 − 0.50", "Q_시공^표 - 0.50"),
|
||||
("C_{m 3} + x_1", "C_{m 3} + x_1"),
|
||||
("반올림(3600 × q ÷ C, 3)", "반올림(3600 × q ÷ C, 3)"),
|
||||
("a 또는 b 그리고 c", "a 또는 b 그리고 c"),
|
||||
("(a 또는 b) 그리고 c", "(a 또는 b) 그리고 c"),
|
||||
],
|
||||
)
|
||||
def test_식_풀이_되돌림(text, want):
|
||||
got = md.text_of(md.parse(text))
|
||||
assert got == want
|
||||
assert md.text_of(md.parse(got)) == got
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text", ["", "a +", "(a", "모름(1)", "a b", "'열림", "1 ++", "만약", "x._"]
|
||||
)
|
||||
def test_틀린_식(text):
|
||||
with pytest.raises(md.DocError):
|
||||
md.parse(text)
|
||||
|
||||
|
||||
def test_기호_글():
|
||||
assert md.sym_id("Q", "시공", "표") == "Q_시공^표"
|
||||
assert md.sym_id("C", "m 3") == "C_{m 3}"
|
||||
assert md.split_id("Q_시공^표") == ("Q", "시공", "표")
|
||||
assert md.split_id("C_{m 3}") == ("C", "m 3", "")
|
||||
assert md.good_id("q′_s") and not md.good_id("노무비") and not md.good_id("1a")
|
||||
|
||||
|
||||
def test_이름_바꿈_전파():
|
||||
doc = SAMPLE()
|
||||
doc["변수"].append(_var("P_x", {"갈래": "요소", "키": "EQ:{S_유형}", "칸": "가격"}))
|
||||
md.rename(doc, "S_유형", "U", "형")
|
||||
assert doc["변수"][0]["기호"] == "U" and doc["변수"][0]["열쇠"] == "유형"
|
||||
assert doc["변수"][2]["연결"]["조건"]["유형"] == "U_형"
|
||||
assert doc["식"][0]["식"] == "Q^표 × (1 + 만약(U_형 == '복잡', r_할증, 0)%)"
|
||||
assert doc["변수"][-1]["연결"]["키"] == "EQ:{U_형}"
|
||||
md.rename(doc, "C_형틀", "M")
|
||||
assert doc["금액"][0]["기호"] == "M" and doc["금액"][3]["단가"] == "M × 1 ÷ 3"
|
||||
with pytest.raises(md.DocError):
|
||||
md.rename(doc, "M", "t") # 같은 기호
|
||||
with pytest.raises(md.DocError):
|
||||
md.rename(doc, "t", "노무비") # 못 쓰는 기호
|
||||
|
||||
|
||||
# ── ② 셈 ──────────────────────────────────────────────────────────────
|
||||
def test_셈_기본_금액_식():
|
||||
got = _calc(SAMPLE(), Fake(LABOR))
|
||||
assert got["셈됨"] and got["표지"] == []
|
||||
# 형틀 0.2 × 274,978 = 54,995.6 · 보통 0.05 × 165,545 = 8,277.25 → 8,277.2
|
||||
# 공구손료 = (54,995.6 + 8,277.2) × 3% = 1,898.184 → 1,898.1 · 소모 = 54,995.6 ÷ 3 = 18,331.8
|
||||
assert [x["금액"] for x in got["줄"]] == [D("54995.6"), D("8277.2"), D("1898.1"), D("18331.8")]
|
||||
assert _money(got) == {"노무비": 63272, "재료비": 18331, "경비": 1898, "계": 83501}
|
||||
assert got["끝수"]["줄"] == {"방법": "버림", "자리": 1} and got["머리"] == "계"
|
||||
got = _calc(SAMPLE(), Fake(LABOR), {"유형": "복잡", "두께": D(5)}) # 넘긴 입력 = 열쇠
|
||||
assert got["값"]["Q_형틀"] == D("0.33")
|
||||
|
||||
|
||||
def test_표_조건_범위_분수():
|
||||
links = Fake(LABOR)
|
||||
doc = SAMPLE()
|
||||
assert _calc(doc, links, {"두께": D(10)})["값"]["Q^표"] == D("0.1") # 초과·이하 — 10 은 아래 줄
|
||||
assert _calc(doc, links, {"두께": D("10.5")})["값"]["Q^표"] == D("0.2")
|
||||
got = _calc(doc, links, {"두께": D(25)}) # 맞는 줄 없음 = 값 없음 → 그 줄만 비움
|
||||
assert got["셈됨"] and "Q^표" in got["빈값"] and got["줄"][0]["금액"] is None
|
||||
assert got["노무비"] == 8277 # 형틀 줄 빠짐
|
||||
doc["변수"].append(
|
||||
_var("k", {"갈래": "표", "표": "QX990001", "조건": {"유형": "'분수'"}, "칸": "품"})
|
||||
)
|
||||
assert _calc(doc, links)["값"]["k"] == D(1) / D("1.30")
|
||||
doc["변수"].append(
|
||||
_var("z", {"갈래": "표", "표": "QX990001", "조건": {"유형": "'복잡'"}, "칸": "율"})
|
||||
)
|
||||
assert "z" in _calc(doc, links)["빈값"] # 그 줄에 칸 없음 = 값 없음
|
||||
doc["변수"].append(
|
||||
_var("y", {"갈래": "표", "표": "QX990001", "조건": {"없는": "1"}, "칸": "품"})
|
||||
)
|
||||
assert any(m["갈래"] == "연결 끊김" for m in _calc(doc, links)["표지"]) # 없는 조건 = 끊김
|
||||
|
||||
|
||||
def test_요소_값_없음_줄만_비움():
|
||||
got = _calc(
|
||||
SAMPLE(), Fake({"LB990001": D(274978), "LB990002": "빈:값 없음 — 관리자가 채울 값"})
|
||||
)
|
||||
assert got["셈됨"] and got["줄"][1]["까닭"].startswith("값 없음")
|
||||
assert got["노무비"] == 54995
|
||||
|
||||
|
||||
def test_단가산출_부름_묶음_칸_결과():
|
||||
hour = _doc(
|
||||
[("w", {"갈래": "숫자", "값": D(100000)}), ("m", {"갈래": "숫자", "값": D("3000.7")})],
|
||||
[],
|
||||
[_man("조종원", "1", "w"), {"이름": "연료", "비목": "재료비", "단가": "m"}],
|
||||
)
|
||||
work = _doc(
|
||||
[("q", {"갈래": "입력", "값": D("0.6")}, "버킷"), ("Q", {"갈래": "식"})],
|
||||
[("Q", "반올림(3600 × q ÷ 18, 3)")],
|
||||
결과="Q",
|
||||
)
|
||||
main = _doc(
|
||||
[
|
||||
("B", {"갈래": "단가산출", "키": "GX990002", "입력": {}}),
|
||||
("B_경", {"갈래": "단가산출", "키": "GX990002", "입력": {}, "칸": "경비"}),
|
||||
("Q_작업", {"갈래": "단가산출", "키": "GX990003", "입력": {"버킷": "0.7"}}),
|
||||
],
|
||||
[],
|
||||
[
|
||||
{"이름": "굴착기", "비목": "묶음", "수량": "1 ÷ Q_작업", "단가": "B", "묶음": "굴착기"},
|
||||
_man("보통인부", "0.01", "B.계", 묶음="인력"),
|
||||
],
|
||||
)
|
||||
links = Fake(docs={"GX990002": hour, "GX990003": work})
|
||||
assert _calc(work, links)["결과"] == D(120)
|
||||
got = _calc(main, links)
|
||||
assert got["값"]["Q_작업"] == D(140) and got["값"]["B"]["계"] == D(103000)
|
||||
assert got["값"]["B_경"] == 0
|
||||
assert [x["이름"] for x in got["묶음"]] == ["굴착기", "인력"] and got["머리"] == "합계"
|
||||
# 굴착기 = 100,000 ÷ 140 = 714.28 → 714.2 · 3,000 ÷ 140 = 21.42 → 21.4 · 인력 0.01 × 103,000 = 1,030
|
||||
assert got["줄"][0]["비목"] == {"노무비": D("714.2"), "재료비": D("21.4"), "경비": D(0)}
|
||||
assert got["묶음"][0]["계"] == D("735.6") and _money(got)["계"] == 1765
|
||||
|
||||
|
||||
def test_반올림_자리_끝전_계_조정():
|
||||
doc = _doc(
|
||||
[("a", {"갈래": "숫자", "값": D("0.35")})],
|
||||
[],
|
||||
[
|
||||
_man("갑", "1", "반올림(a × 10, 0)", 자리={"방법": "반올림", "자리": 0}),
|
||||
_man("을", "1", "올림(a, 1) + 버림(a, 1)", "재료비"),
|
||||
_man("병", "1", "1.6", "경비"),
|
||||
],
|
||||
)
|
||||
got = _calc(doc)
|
||||
assert [x["금액"] for x in got["줄"]] == [D(4), D("0.7"), D("1.6")]
|
||||
assert _money(got) == {"노무비": 4, "재료비": 0, "경비": 1, "계": 5}
|
||||
doc["끝수"]["끝전"] = "큰나머지" # 4 + 0.7 + 1.6 = 6.3 → 6 · 모자란 1 원 = 원 미만 큰 재료비
|
||||
assert _money(_calc(doc)) == {"노무비": 4, "재료비": 1, "경비": 1, "계": 6}
|
||||
doc["조정"] = {"배율": {"노무비": D("0.5")}, "옮김": "경비"}
|
||||
assert _money(_calc(doc)) == {"노무비": 0, "재료비": 0, "경비": 4, "계": 4}
|
||||
doc.pop("조정")
|
||||
doc["끝수"] = {
|
||||
"줄": {"방법": "버림", "자리": 0},
|
||||
"비목": {"방법": "버림", "자리": 0},
|
||||
"계": {"방법": "반올림", "자리": -1},
|
||||
}
|
||||
assert any(m["갈래"] == "끝수" for m in _calc(doc)["표지"]) # 자리 꼴 틀림 = 표지 · 멈춤
|
||||
|
||||
|
||||
def test_번지_단계_합밖():
|
||||
doc = _doc(
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{"이름": "갑", "비목": "재료비", "단가": "1000", "번지": "A01"},
|
||||
{"이름": "을", "비목": "재료비", "단가": "500", "번지": "A01"},
|
||||
{"이름": "형틀", "비목": "노무비", "단가": "2000", "번지": "A02"},
|
||||
{"이름": "소모", "비목": "재료비", "단가": "번지.A01.재료비 × 10 ÷ 100"},
|
||||
{"이름": "참고", "비목": "경비", "단가": "999", "합밖": True},
|
||||
{"이름": "손료", "비목": "경비", "단가": "단계.'설치'.계 × 1 ÷ 100"},
|
||||
],
|
||||
단계=[
|
||||
{"이름": "자재", "단": 1, "줄": ["갑", "을", "소모"]},
|
||||
{"이름": "설치", "단": 1, "줄": ["형틀"]},
|
||||
{"이름": "계", "단": 2},
|
||||
],
|
||||
)
|
||||
got = _calc(doc)
|
||||
assert [x["금액"] for x in got["줄"]] == [1000, 500, 2000, 150, 999, 20]
|
||||
assert _money(got) == {"노무비": 2000, "재료비": 1650, "경비": 20, "계": 3670}
|
||||
assert [x["계"] for x in got["단계"]] == [1650, 2000, 3650] and got["줄"][4]["합밖"]
|
||||
|
||||
|
||||
def test_결과_문서_입력_빔():
|
||||
doc = _doc(
|
||||
[("q", {"갈래": "입력", "값": None}), ("Q", {"갈래": "식"})], [("Q", "q × 2")], 결과="Q"
|
||||
)
|
||||
got = _calc(doc)
|
||||
assert got["결과"] is None and not got["셈됨"] and "비어 있음" in got["까닭"]
|
||||
assert _calc(doc, given={"q": "3"})["결과"] == 6 # 글 수 = 수
|
||||
|
||||
|
||||
# ── ③ 표지 ────────────────────────────────────────────────────────────
|
||||
def _kinds(got):
|
||||
return {m["갈래"] for m in got["표지"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lines, kind",
|
||||
[
|
||||
([("a", "b + 1")], "정의 필요"),
|
||||
([("a", "b + 1"), ("b", "a × 2")], "순환"),
|
||||
([("a", "1 ÷ (2 - 2)")], "0 나눔"),
|
||||
([("a", "1 +")], "식 틀림"),
|
||||
([("a", "'글' × 2")], "셈 틀림"),
|
||||
([("a", "노무비 × 2")], "금액 이름"),
|
||||
],
|
||||
)
|
||||
def test_표지_금액_줄표(lines, kind):
|
||||
doc = _doc([], lines, [_man("갑", "1", "a")])
|
||||
got = _calc(doc)
|
||||
assert kind in _kinds(got)
|
||||
assert not got["셈됨"] and got["계"] is None and got["노무비"] is None
|
||||
|
||||
|
||||
def test_빈_문서_겹침_순환_부름():
|
||||
got = _calc(md.blank())
|
||||
assert not got["셈됨"] and got["계"] is None and _kinds(got) == {"빈 문서"}
|
||||
doc = _doc([("a", {"갈래": "숫자", "값": D(1)})], [("a", "2")], [_man("갑", "1", "a")])
|
||||
assert "겹침" in _kinds(_calc(doc)) and _calc(doc)["계"] is None
|
||||
loop = _doc(
|
||||
[("B", {"갈래": "단가산출", "키": "GX990001", "입력": {}})], [], [_man("갑", "1", "B.계")]
|
||||
)
|
||||
got = _calc(loop, Fake(docs={"GX990001": loop}))
|
||||
assert not got["셈됨"] and "순환" in _kinds(got)
|
||||
|
||||
|
||||
def test_정적_검사():
|
||||
doc = SAMPLE()
|
||||
assert md.check(doc) == []
|
||||
doc["식"].append({"기호": "x", "식": "y + Q_형틀"})
|
||||
doc["식"].append({"기호": "y", "식": "x"})
|
||||
kinds = [m["갈래"] for m in md.check(doc)]
|
||||
assert "순환" in kinds and "정의 필요" not in kinds
|
||||
|
||||
|
||||
# ── ④ 지움 견딤 ───────────────────────────────────────────────────────
|
||||
_ROWS: dict = {}
|
||||
|
||||
|
||||
class Lazy(ml.Links):
|
||||
"""M01 연결 + 부르는 문서는 그때 변환(옛 로직 → 수식 문서)."""
|
||||
|
||||
def doc(self, key):
|
||||
try:
|
||||
row = self.master.get(key)
|
||||
except Exception:
|
||||
return None
|
||||
key = str(row["키"])
|
||||
if key not in self.docs and key in _ROWS:
|
||||
self.docs[key] = cv.convert(self.master, _ROWS[key])[0]
|
||||
return self.docs.get(key)
|
||||
|
||||
|
||||
def _real_docs(keys):
|
||||
files, whole = m01.loaded()
|
||||
_ROWS.update({r["키"]: r for d in files.values() if d.get("그룹") == "로직" for r in d["줄"]})
|
||||
links = Lazy(whole, {})
|
||||
return links, {key: links.doc(key) for key in keys}
|
||||
|
||||
|
||||
REAL = (
|
||||
"GC000211",
|
||||
"GF000294",
|
||||
"GC001147",
|
||||
"GC000100",
|
||||
"GF000185",
|
||||
"GC000087",
|
||||
"GF000221",
|
||||
"GC000278",
|
||||
)
|
||||
|
||||
|
||||
def _cuts(doc):
|
||||
"""지운 문서들 — 변수 · 식 줄 · 금액 줄 하나씩 · 식 글 하나씩 빈 글 · 반 토막."""
|
||||
for kind in ("변수", "식", "금액"):
|
||||
for i in range(len(doc.get(kind) or [])):
|
||||
one = copy.deepcopy(doc)
|
||||
del one[kind][i]
|
||||
yield one
|
||||
for i, line in enumerate(doc.get("식") or []):
|
||||
for text in ("", line["식"][: len(line["식"]) // 2]):
|
||||
one = copy.deepcopy(doc)
|
||||
one["식"][i]["식"] = text
|
||||
yield one
|
||||
for i, line in enumerate(doc.get("금액") or []):
|
||||
for k in ("수량", "단가"):
|
||||
one = copy.deepcopy(doc)
|
||||
one["금액"][i][k] = ""
|
||||
yield one
|
||||
one = copy.deepcopy(doc)
|
||||
one.pop("결과", None)
|
||||
yield one
|
||||
|
||||
|
||||
def test_지움_견딤_예외_0():
|
||||
links, docs = _real_docs(REAL)
|
||||
count = 0
|
||||
for doc in [SAMPLE(), *docs.values()]:
|
||||
base = _calc(doc, links if doc.get("키") in docs else Fake(LABOR))
|
||||
assert base["셈됨"]
|
||||
for one in _cuts(doc):
|
||||
got = _calc(one, links if doc.get("키") in docs else Fake(LABOR)) # 예외 없음
|
||||
count += 1
|
||||
assert set(got) >= {"값", "표지", "셈됨", "끝수"}
|
||||
if not got["셈됨"]:
|
||||
assert got.get("계") is None and got.get("결과") is None
|
||||
assert got["표지"] or got.get("까닭")
|
||||
assert count > 300
|
||||
|
||||
|
||||
# ── ⑤ 거울 ────────────────────────────────────────────────────────────
|
||||
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:
|
||||
"""연결 기록 — 같은 연결 값을 TS 에 넘김."""
|
||||
|
||||
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 _ts(payload):
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_802_1_formula_doc.cjs")],
|
||||
input=json.dumps(ml._norm(payload), ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def test_거울_파이썬_TS_같은_답():
|
||||
links, _ = _real_docs(())
|
||||
picks = sorted(_ROWS)[::30] + list(REAL)
|
||||
for key in picks:
|
||||
links.doc(key)
|
||||
rec = Rec(links)
|
||||
want = {key: mc.calc(links.docs[key], rec) for key in picks}
|
||||
all_docs = links.docs
|
||||
fakes = {
|
||||
"SAMPLE": (SAMPLE(), Fake(LABOR)),
|
||||
"빈": (md.blank(), Fake()),
|
||||
"순환": (_doc([], [("a", "b"), ("b", "a")], [_man("갑", "1", "a")]), Fake()),
|
||||
"0나눔": (_doc([], [("a", "1 ÷ 0")], [_man("갑", "1", "a")]), Fake()),
|
||||
}
|
||||
for name, (doc, links) in fakes.items():
|
||||
one = Rec(links)
|
||||
want[name] = mc.calc(doc, one)
|
||||
rec.log.update(one.log)
|
||||
all_docs[name] = doc
|
||||
texts = [
|
||||
"a*b/c",
|
||||
"-(a+b)%",
|
||||
"만약(S=='x', 1, 2)",
|
||||
"단계.'소계'.계 × 2",
|
||||
"a +",
|
||||
"C_{m 3}^표 ÷ 2",
|
||||
]
|
||||
cases = [
|
||||
{"조건": {"유형": "보통", "두께": D(t)}, "칸": "품"}
|
||||
for t in ("5", "10", "10.5", "20", "25")
|
||||
] + [{"조건": {"유형": "분수"}, "칸": "품"}, {"조건": {"유형": "복잡"}, "칸": "율"}]
|
||||
got = _ts(
|
||||
{
|
||||
"문서들": all_docs,
|
||||
"대상": list(want),
|
||||
"기록": rec.log,
|
||||
"글": texts,
|
||||
"표": [{"표": TABLE, **c} for c in cases],
|
||||
}
|
||||
)
|
||||
for key, answer in want.items():
|
||||
_same(ml._norm(answer), got["답"][key], key)
|
||||
for text, ts_text in zip(texts, got["글"]):
|
||||
try:
|
||||
assert md.text_of(md.parse(text)) == ts_text
|
||||
except md.DocError:
|
||||
assert ts_text == {"틀림": True}
|
||||
for case, ts_value in zip(cases, got["표"]):
|
||||
try:
|
||||
py = {"값": ml.Links.table(_TableOnly(TABLE), {"표": "QX990001", **case})}
|
||||
except mc.Gap:
|
||||
py = {"빈": True}
|
||||
_same(ml._norm(py), ts_value, str(case))
|
||||
assert len(want) >= 60
|
||||
Reference in New Issue
Block a user