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:
2026-11-07 18:41:39 +09:00
co-authored by Claude Opus 5.5
parent 2376e70fbb
commit f7038cc280
10 changed files with 4004 additions and 0 deletions
@@ -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));
+588
View File
@@ -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